detached 77859cd

Merge branch 'feat/barebone-bash-dank-linux-opt-in' into dev

commit
77859cd8b0f5a4e964d1e965deada03aec41700a
author
brice · · 2026-02-27 04:59 UTC
merge of
7030359 + dd03646 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +411 -2397
D cmd/wizard.go +0 -65
@@ -1,65 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/infrastructure/executor"
9- "github.com/bnema/archup/internal/infrastructure/filesystem"
10- infralogger "github.com/bnema/archup/internal/infrastructure/logger"
11- "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/wizard/application/services"
13- "github.com/bnema/archup/internal/wizard/interfaces/tui"
14- tea "github.com/charmbracelet/bubbletea"
15- "github.com/spf13/cobra"
16-)
17-
18-func init() {
19- rootCmd.AddCommand(newWizardCmd())
20-}
21-
22-func newWizardCmd() *cobra.Command {
23- var dryRun bool
24- cmd := &cobra.Command{
25- Use: "wizard",
26- Short: "Run post-boot desktop wizard",
27- RunE: func(cmd *cobra.Command, args []string) error {
28- return runWizard(dryRun)
29- },
30- }
31- cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show TUI but don't execute commands")
32- return cmd
33-}
34-
35-func runWizard(dryRun bool) error {
36- oldLog, err := logger.New(config.DefaultLogPath, dryRun)
37- if err != nil {
38- return fmt.Errorf("failed to create logger: %w", err)
39- }
40- defer func() {
41- if err := oldLog.Close(); err != nil {
42- fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
43- }
44- }()
45-
46- if dryRun {
47- oldLog.Info("Running wizard in DRY-RUN mode - commands will be logged but not executed")
48- }
49-
50- slogAdapter := infralogger.NewSlogAdapter(oldLog.Slog())
51- fsAdapter := &filesystem.LocalFileSystem{}
52- shellExec := executor.NewShellExecutor(slogAdapter)
53-
54- wizardService := services.NewWizardService(fsAdapter, shellExec, slogAdapter)
55- app := tui.NewApp(wizardService, slogAdapter)
56-
57- oldLog.Info("Starting wizard TUI")
58- p := tea.NewProgram(app, tea.WithAltScreen())
59- if _, err := p.Run(); err != nil {
60- oldLog.Error("Error running wizard", "error", err)
61- return fmt.Errorf("run wizard: %w", err)
62- }
63-
64- return nil
65-}
@@ -1,65 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6-
7- "github.com/bnema/archup/internal/config"
8- "github.com/bnema/archup/internal/infrastructure/executor"
9- "github.com/bnema/archup/internal/infrastructure/filesystem"
10- infralogger "github.com/bnema/archup/internal/infrastructure/logger"
11- "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/wizard/application/services"
13- "github.com/bnema/archup/internal/wizard/interfaces/tui"
14- tea "github.com/charmbracelet/bubbletea"
15- "github.com/spf13/cobra"
16-)
17-
18-func init() {
19- rootCmd.AddCommand(newWizardCmd())
20-}
21-
22-func newWizardCmd() *cobra.Command {
23- var dryRun bool
24- cmd := &cobra.Command{
25- Use: "wizard",
26- Short: "Run post-boot desktop wizard",
27- RunE: func(cmd *cobra.Command, args []string) error {
28- return runWizard(dryRun)
29- },
30- }
31- cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show TUI but don't execute commands")
32- return cmd
33-}
34-
35-func runWizard(dryRun bool) error {
36- oldLog, err := logger.New(config.DefaultLogPath, dryRun)
37- if err != nil {
38- return fmt.Errorf("failed to create logger: %w", err)
39- }
40- defer func() {
41- if err := oldLog.Close(); err != nil {
42- fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
43- }
44- }()
45-
46- if dryRun {
47- oldLog.Info("Running wizard in DRY-RUN mode - commands will be logged but not executed")
48- }
49-
50- slogAdapter := infralogger.NewSlogAdapter(oldLog.Slog())
51- fsAdapter := &filesystem.LocalFileSystem{}
52- shellExec := executor.NewShellExecutor(slogAdapter)
53-
54- wizardService := services.NewWizardService(fsAdapter, shellExec, slogAdapter)
55- app := tui.NewApp(wizardService, slogAdapter)
56-
57- oldLog.Info("Starting wizard TUI")
58- p := tea.NewProgram(app, tea.WithAltScreen())
59- if _, err := p.Run(); err != nil {
60- oldLog.Error("Error running wizard", "error", err)
61- return fmt.Errorf("run wizard: %w", err)
62- }
63-
64- return nil
65-}
M install/mandatory/post-boot/all.sh +15 -0
@@ -68,6 +68,21 @@ if [ -f /usr/local/share/archup/post-boot/blesh.sh ]; then
6868 fi
6969 fi
7070
71+# Install modern CLI toolkit
72+if [ -f /usr/local/share/archup/post-boot/cli-tools.sh ]; then
73+ echo "Installing modern CLI toolkit..." | tee -a "$LOG_FILE"
74+ if bash /usr/local/share/archup/post-boot/cli-tools.sh >> "$LOG_FILE" 2>&1; then
75+ echo "[OK] CLI tools installed" | tee -a "$LOG_FILE"
76+ else
77+ echo "[KO] CLI tools installation failed (non-critical)" | tee -a "$LOG_FILE"
78+ fi
79+fi
80+
81+# Optional Dank Linux installation (interactive — do NOT pipe stdin)
82+if [ -f /usr/local/share/archup/post-boot/dms-opt-in.sh ]; then
83+ bash /usr/local/share/archup/post-boot/dms-opt-in.sh || true
84+fi
85+
7186 echo "=== First Boot Setup Complete ===" >> "$LOG_FILE"
7287
7388 # Disable this service after first run
@@ -68,6 +68,21 @@ if [ -f /usr/local/share/archup/post-boot/blesh.sh ]; then
68 fi68 fi
69 fi69 fi
70 70
71+# Install modern CLI toolkit
72+if [ -f /usr/local/share/archup/post-boot/cli-tools.sh ]; then
73+ echo "Installing modern CLI toolkit..." | tee -a "$LOG_FILE"
74+ if bash /usr/local/share/archup/post-boot/cli-tools.sh >> "$LOG_FILE" 2>&1; then
75+ echo "[OK] CLI tools installed" | tee -a "$LOG_FILE"
76+ else
77+ echo "[KO] CLI tools installation failed (non-critical)" | tee -a "$LOG_FILE"
78+ fi
79+fi
80+
81+# Optional Dank Linux installation (interactive — do NOT pipe stdin)
82+if [ -f /usr/local/share/archup/post-boot/dms-opt-in.sh ]; then
83+ bash /usr/local/share/archup/post-boot/dms-opt-in.sh || true
84+fi
85+
71 echo "=== First Boot Setup Complete ===" >> "$LOG_FILE"86 echo "=== First Boot Setup Complete ===" >> "$LOG_FILE"
72 87
73 # Disable this service after first run88 # Disable this service after first run
M install/mandatory/post-boot/archup-first-boot.service +5 -2
@@ -1,6 +1,7 @@
11 [Unit]
22 Description=ArchUp First Boot Setup
3-After=local-fs.target systemd-logind.service dbus.service
3+After=local-fs.target systemd-logind.service dbus.service network-online.target
4+Wants=network-online.target
45 ConditionPathExists=!/var/lib/archup-first-boot-complete
56
67 [Service]
@@ -9,7 +10,9 @@ Environment="ARCHUP_EMAIL=__ARCHUP_EMAIL__"
910 Environment="ARCHUP_USERNAME=__ARCHUP_USERNAME__"
1011 ExecStart=/usr/local/share/archup/post-boot/all.sh
1112 ExecStartPost=/usr/bin/touch /var/lib/archup-first-boot-complete
12-StandardOutput=journal+console
13+TTYPath=/dev/tty1
14+StandardInput=tty
15+StandardOutput=tty
1316 StandardError=journal+console
1417
1518 [Install]
@@ -1,6 +1,7 @@
1 [Unit]1 [Unit]
2 Description=ArchUp First Boot Setup2 Description=ArchUp First Boot Setup
3-After=local-fs.target systemd-logind.service dbus.service3+After=local-fs.target systemd-logind.service dbus.service network-online.target
4+Wants=network-online.target
4 ConditionPathExists=!/var/lib/archup-first-boot-complete5 ConditionPathExists=!/var/lib/archup-first-boot-complete
5 6
6 [Service]7 [Service]
@@ -9,7 +10,9 @@ Environment="ARCHUP_EMAIL=__ARCHUP_EMAIL__"
9 Environment="ARCHUP_USERNAME=__ARCHUP_USERNAME__"10 Environment="ARCHUP_USERNAME=__ARCHUP_USERNAME__"
10 ExecStart=/usr/local/share/archup/post-boot/all.sh11 ExecStart=/usr/local/share/archup/post-boot/all.sh
11 ExecStartPost=/usr/bin/touch /var/lib/archup-first-boot-complete12 ExecStartPost=/usr/bin/touch /var/lib/archup-first-boot-complete
12-StandardOutput=journal+console13+TTYPath=/dev/tty1
14+StandardInput=tty
15+StandardOutput=tty
13 StandardError=journal+console16 StandardError=journal+console
14 17
15 [Install]18 [Install]
A install/mandatory/post-boot/cli-tools.sh +85 -0
@@ -0,0 +1,85 @@
1+#!/bin/bash
2+# cli-tools.sh — Install modern CLI toolkit (barebone, no DE or theme)
3+set -euo pipefail
4+
5+LOG_FILE="/var/log/archup-first-boot.log"
6+USERNAME=$(getent passwd | awk -F: '$3 >= 1000 && $3 < 65534 && $1 != "nobody" {print $1; exit}')
7+USER_HOME=$(getent passwd "$USERNAME" | cut -d: -f6)
8+
9+log() { echo "[cli-tools] $*" | tee -a "$LOG_FILE"; }
10+
11+if [[ -z "$USERNAME" ]]; then
12+ log "ERROR: Could not detect user via getent passwd"
13+ exit 1
14+fi
15+
16+log "Installing modern CLI toolkit for $USERNAME..."
17+
18+log "Upgrading system and refreshing package databases..."
19+pacman -Syu --noconfirm
20+
21+pacman -S --needed --noconfirm \
22+ eza bat fd ripgrep fzf zoxide starship \
23+ btop yazi git-delta gdu procs tealdeer \
24+ lazygit atuin man-db neovim wget curl git
25+
26+log "Configuring shell environment for $USERNAME..."
27+
28+# Remove any previous archup block (idempotent re-run)
29+if [ -f "$USER_HOME/.bashrc" ]; then
30+ sed -i '/# BEGIN archup cli tools/,/# END archup cli tools/d' "$USER_HOME/.bashrc"
31+fi
32+
33+# Append archup block
34+cat >> "$USER_HOME/.bashrc" << 'BASHRC'
35+# BEGIN archup cli tools
36+# Aliases
37+alias ls='eza --icons'
38+alias ll='eza -la --icons --git'
39+alias la='eza -a --icons'
40+alias lt='eza --tree --icons'
41+alias cat='bat'
42+alias grep='rg'
43+alias find='fd'
44+alias top='btop'
45+alias vim='nvim'
46+alias vi='nvim'
47+
48+# Git delta pager
49+export GIT_PAGER='delta'
50+
51+# Man pages rendered with bat
52+export MANPAGER="sh -c 'col -bx | bat -l man -p'"
53+
54+# fzf key bindings and fuzzy completion
55+source <(fzf --bash) 2>/dev/null || true
56+
57+# zoxide (smart cd with frecency)
58+eval "$(zoxide init bash)"
59+[[ $- == *i* ]] && alias cd='z'
60+
61+# atuin (shell history search)
62+eval "$(atuin init bash)" 2>/dev/null || true
63+
64+# starship prompt (no custom theme — uses default)
65+eval "$(starship init bash)"
66+
67+# ble.sh (Bash Line Editor) — must be sourced last
68+[[ $- == *i* ]] && source -- ~/.local/share/blesh/ble.sh 2>/dev/null || true
69+# END archup cli tools
70+BASHRC
71+
72+chown "$USERNAME:$USERNAME" "$USER_HOME/.bashrc"
73+
74+# Update tldr cache
75+sudo -u "$USERNAME" tldr --update 2>/dev/null || true
76+
77+# git delta global config
78+sudo -u "$USERNAME" git config --global core.pager delta
79+sudo -u "$USERNAME" git config --global interactive.diffFilter "delta --color-only"
80+sudo -u "$USERNAME" git config --global delta.navigate true
81+sudo -u "$USERNAME" git config --global delta.side-by-side true
82+sudo -u "$USERNAME" git config --global merge.conflictstyle diff3
83+sudo -u "$USERNAME" git config --global diff.colorMoved default
84+
85+log "CLI toolkit installed successfully."
@@ -0,0 +1,85 @@
1+#!/bin/bash
2+# cli-tools.sh — Install modern CLI toolkit (barebone, no DE or theme)
3+set -euo pipefail
4+
5+LOG_FILE="/var/log/archup-first-boot.log"
6+USERNAME=$(getent passwd | awk -F: '$3 >= 1000 && $3 < 65534 && $1 != "nobody" {print $1; exit}')
7+USER_HOME=$(getent passwd "$USERNAME" | cut -d: -f6)
8+
9+log() { echo "[cli-tools] $*" | tee -a "$LOG_FILE"; }
10+
11+if [[ -z "$USERNAME" ]]; then
12+ log "ERROR: Could not detect user via getent passwd"
13+ exit 1
14+fi
15+
16+log "Installing modern CLI toolkit for $USERNAME..."
17+
18+log "Upgrading system and refreshing package databases..."
19+pacman -Syu --noconfirm
20+
21+pacman -S --needed --noconfirm \
22+ eza bat fd ripgrep fzf zoxide starship \
23+ btop yazi git-delta gdu procs tealdeer \
24+ lazygit atuin man-db neovim wget curl git
25+
26+log "Configuring shell environment for $USERNAME..."
27+
28+# Remove any previous archup block (idempotent re-run)
29+if [ -f "$USER_HOME/.bashrc" ]; then
30+ sed -i '/# BEGIN archup cli tools/,/# END archup cli tools/d' "$USER_HOME/.bashrc"
31+fi
32+
33+# Append archup block
34+cat >> "$USER_HOME/.bashrc" << 'BASHRC'
35+# BEGIN archup cli tools
36+# Aliases
37+alias ls='eza --icons'
38+alias ll='eza -la --icons --git'
39+alias la='eza -a --icons'
40+alias lt='eza --tree --icons'
41+alias cat='bat'
42+alias grep='rg'
43+alias find='fd'
44+alias top='btop'
45+alias vim='nvim'
46+alias vi='nvim'
47+
48+# Git delta pager
49+export GIT_PAGER='delta'
50+
51+# Man pages rendered with bat
52+export MANPAGER="sh -c 'col -bx | bat -l man -p'"
53+
54+# fzf key bindings and fuzzy completion
55+source <(fzf --bash) 2>/dev/null || true
56+
57+# zoxide (smart cd with frecency)
58+eval "$(zoxide init bash)"
59+[[ $- == *i* ]] && alias cd='z'
60+
61+# atuin (shell history search)
62+eval "$(atuin init bash)" 2>/dev/null || true
63+
64+# starship prompt (no custom theme — uses default)
65+eval "$(starship init bash)"
66+
67+# ble.sh (Bash Line Editor) — must be sourced last
68+[[ $- == *i* ]] && source -- ~/.local/share/blesh/ble.sh 2>/dev/null || true
69+# END archup cli tools
70+BASHRC
71+
72+chown "$USERNAME:$USERNAME" "$USER_HOME/.bashrc"
73+
74+# Update tldr cache
75+sudo -u "$USERNAME" tldr --update 2>/dev/null || true
76+
77+# git delta global config
78+sudo -u "$USERNAME" git config --global core.pager delta
79+sudo -u "$USERNAME" git config --global interactive.diffFilter "delta --color-only"
80+sudo -u "$USERNAME" git config --global delta.navigate true
81+sudo -u "$USERNAME" git config --global delta.side-by-side true
82+sudo -u "$USERNAME" git config --global merge.conflictstyle diff3
83+sudo -u "$USERNAME" git config --global diff.colorMoved default
84+
85+log "CLI toolkit installed successfully."
A install/mandatory/post-boot/dms-opt-in.sh +73 -0
@@ -0,0 +1,73 @@
1+#!/bin/bash
2+# dms-opt-in.sh — Optional Dank Linux full suite installation prompt
3+# Runs at end of first-boot setup. Asks user if they want the full
4+# Dank Linux desktop (compositor + DMS shell + terminal + all tools).
5+set -euo pipefail
6+
7+LOG_FILE="/var/log/archup-first-boot.log"
8+
9+log() { echo "[dms-opt-in] $*" | tee -a "$LOG_FILE"; }
10+
11+FLAG_FILE="/var/lib/archup-install-danklinux"
12+
13+# Auto-install if flag was set during installation
14+if [ -f "$FLAG_FILE" ]; then
15+ log "Dank Linux install flag detected — running installer automatically."
16+ echo ""
17+ echo "Installing Dank Linux (selected during system installation)..."
18+ if curl -fsSL https://install.danklinux.com | sh; then
19+ rm -f "$FLAG_FILE"
20+ log "Dank Linux installation complete."
21+ else
22+ log "Dank Linux installer failed — flag preserved at $FLAG_FILE for retry."
23+ exit 1
24+ fi
25+ exit 0
26+fi
27+
28+# Skip if not running in an interactive terminal
29+if [ ! -t 0 ]; then
30+ log "Not running in interactive terminal — skipping Dank Linux opt-in."
31+ exit 0
32+fi
33+
34+# Manual prompt fallback (for reinstall / re-run scenarios)
35+echo ""
36+echo "=============================================="
37+echo " Dank Linux — Optional Install"
38+echo "=============================================="
39+echo ""
40+echo " Dank Linux is a full Wayland desktop suite:"
41+echo " - Compositor: niri or Hyprland (your choice)"
42+echo " - Shell: DankMaterialShell (Quickshell/Go)"
43+echo " - Terminal: Ghostty"
44+echo " - Auto-theming, notifications, app launcher,"
45+echo " lock screen, system tray, search — all"
46+echo " integrated. One install. Everything works."
47+echo ""
48+echo " Install: curl -fsSL https://install.danklinux.com | sh"
49+echo " Note: This runs the official Dank Linux installer. Review at:"
50+echo " https://github.com/AvengeMedia/DankMaterialShell"
51+echo ""
52+
53+read -r -p " Do you want to run the Dank Linux installer? [y/N] " REPLY || true
54+echo ""
55+
56+case "$REPLY" in
57+ [yY][eE][sS]|[yY])
58+ log "User opted in — starting Dank Linux installer."
59+ echo "Starting Dank Linux installer..."
60+ if curl -fsSL https://install.danklinux.com | sh; then
61+ log "Dank Linux installation complete."
62+ else
63+ log "ERROR: Dank Linux installer failed (exit code $?)."
64+ exit 1
65+ fi
66+ ;;
67+ *)
68+ log "User skipped Dank Linux installation."
69+ echo "Skipping. Install later with:"
70+ echo " curl -fsSL https://install.danklinux.com | sh"
71+ echo ""
72+ ;;
73+esac
@@ -0,0 +1,73 @@
1+#!/bin/bash
2+# dms-opt-in.sh — Optional Dank Linux full suite installation prompt
3+# Runs at end of first-boot setup. Asks user if they want the full
4+# Dank Linux desktop (compositor + DMS shell + terminal + all tools).
5+set -euo pipefail
6+
7+LOG_FILE="/var/log/archup-first-boot.log"
8+
9+log() { echo "[dms-opt-in] $*" | tee -a "$LOG_FILE"; }
10+
11+FLAG_FILE="/var/lib/archup-install-danklinux"
12+
13+# Auto-install if flag was set during installation
14+if [ -f "$FLAG_FILE" ]; then
15+ log "Dank Linux install flag detected — running installer automatically."
16+ echo ""
17+ echo "Installing Dank Linux (selected during system installation)..."
18+ if curl -fsSL https://install.danklinux.com | sh; then
19+ rm -f "$FLAG_FILE"
20+ log "Dank Linux installation complete."
21+ else
22+ log "Dank Linux installer failed — flag preserved at $FLAG_FILE for retry."
23+ exit 1
24+ fi
25+ exit 0
26+fi
27+
28+# Skip if not running in an interactive terminal
29+if [ ! -t 0 ]; then
30+ log "Not running in interactive terminal — skipping Dank Linux opt-in."
31+ exit 0
32+fi
33+
34+# Manual prompt fallback (for reinstall / re-run scenarios)
35+echo ""
36+echo "=============================================="
37+echo " Dank Linux — Optional Install"
38+echo "=============================================="
39+echo ""
40+echo " Dank Linux is a full Wayland desktop suite:"
41+echo " - Compositor: niri or Hyprland (your choice)"
42+echo " - Shell: DankMaterialShell (Quickshell/Go)"
43+echo " - Terminal: Ghostty"
44+echo " - Auto-theming, notifications, app launcher,"
45+echo " lock screen, system tray, search — all"
46+echo " integrated. One install. Everything works."
47+echo ""
48+echo " Install: curl -fsSL https://install.danklinux.com | sh"
49+echo " Note: This runs the official Dank Linux installer. Review at:"
50+echo " https://github.com/AvengeMedia/DankMaterialShell"
51+echo ""
52+
53+read -r -p " Do you want to run the Dank Linux installer? [y/N] " REPLY || true
54+echo ""
55+
56+case "$REPLY" in
57+ [yY][eE][sS]|[yY])
58+ log "User opted in — starting Dank Linux installer."
59+ echo "Starting Dank Linux installer..."
60+ if curl -fsSL https://install.danklinux.com | sh; then
61+ log "Dank Linux installation complete."
62+ else
63+ log "ERROR: Dank Linux installer failed (exit code $?)."
64+ exit 1
65+ fi
66+ ;;
67+ *)
68+ log "User skipped Dank Linux installation."
69+ echo "Skipping. Install later with:"
70+ echo " curl -fsSL https://install.danklinux.com | sh"
71+ echo ""
72+ ;;
73+esac
M internal/application/commands/post_install.go +1 -0
@@ -7,4 +7,5 @@ type PostInstallCommand struct {
77 UserEmail string // User email for git config and SSH key (optional)
88 RunPostBootScripts bool // Whether to run post-boot scripts
99 PlymouthTheme string // Plymouth theme to install (optional)
10+ InstallDankLinux bool // Whether to write the Dank Linux flag file for first-boot auto-install
1011 }
@@ -7,4 +7,5 @@ type PostInstallCommand struct {
7 UserEmail string // User email for git config and SSH key (optional)7 UserEmail string // User email for git config and SSH key (optional)
8 RunPostBootScripts bool // Whether to run post-boot scripts8 RunPostBootScripts bool // Whether to run post-boot scripts
9 PlymouthTheme string // Plymouth theme to install (optional)9 PlymouthTheme string // Plymouth theme to install (optional)
10+ InstallDankLinux bool // Whether to write the Dank Linux flag file for first-boot auto-install
10 }11 }
M internal/application/handlers/post_install_handler.go +22 -31
@@ -59,6 +59,16 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
5959 h.logger.Info("Post-boot scripts prepared")
6060 }
6161
62+ // Write Dank Linux flag file if user opted in
63+ if cmd.InstallDankLinux {
64+ if err := h.writeDankLinuxFlag(cmd.MountPoint); err != nil {
65+ result.ErrorDetail = fmt.Sprintf("Failed to write Dank Linux flag: %v", err)
66+ return result, fmt.Errorf("failed to write dank linux flag: %w", err)
67+ }
68+ result.TasksRun = append(result.TasksRun, "dank-linux-flag")
69+ h.logger.Info("Dank Linux flag file written")
70+ }
71+
6272 // Install Plymouth theme if specified
6373 if cmd.PlymouthTheme != "" {
6474 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)
@@ -120,14 +130,6 @@ func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, user
120130 }
121131 }
122132
123- if err := h.copyShellConfigs(mountPoint, username); err != nil {
124- return fmt.Errorf("failed to copy shell configs: %w", err)
125- }
126-
127- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "chown", "-R", fmt.Sprintf("%s:%s", username, username), filepath.Join("/home", username, ".config")); err != nil {
128- return fmt.Errorf("failed to set shell config ownership: %w", err)
129- }
130-
131133 if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", config.PostBootServiceName); err != nil {
132134 return fmt.Errorf("failed to enable first-boot service: %w", err)
133135 }
@@ -167,30 +169,9 @@ func (h *PostInstallHandler) installLimineLogo(mountPoint string) error {
167169 return nil
168170 }
169171
172+// copyShellConfigs is a no-op: shell configuration is handled by
173+// cli-tools.sh on first boot. No config files are copied at install time.
170174 func (h *PostInstallHandler) copyShellConfigs(mountPoint, username string) error {
171- configDir := filepath.Join(mountPoint, "home", username, ".config")
172- if err := h.fs.MkdirAll(configDir, 0755); err != nil {
173- return fmt.Errorf("failed to create user config dir: %w", err)
174- }
175-
176- files := []string{
177- "install/configs/shell/aliases",
178- "install/configs/shell/envs",
179- "install/configs/shell/init",
180- "install/configs/shell/rc",
181- "install/configs/shell/shell",
182- "install/configs/shell/starship.toml",
183- "install/configs/shell/bashrc",
184- }
185-
186- for _, src := range files {
187- _, name := filepath.Split(src)
188- dst := filepath.Join(configDir, name)
189- if err := h.writeFromTemplate(src, dst); err != nil {
190- return err
191- }
192- }
193-
194175 return nil
195176 }
196177
@@ -235,6 +216,16 @@ func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
235216 return nil, fmt.Errorf("local template not found")
236217 }
237218
219+// writeDankLinuxFlag writes a flag file to the installed system so that
220+// dms-opt-in.sh auto-runs without prompting on first boot.
221+func (h *PostInstallHandler) writeDankLinuxFlag(mountPoint string) error {
222+ if err := h.fs.MkdirAll(filepath.Join(mountPoint, "var", "lib"), 0755); err != nil {
223+ return fmt.Errorf("failed to create /var/lib: %w", err)
224+ }
225+ flagPath := filepath.Join(mountPoint, "var", "lib", "archup-install-danklinux")
226+ return h.fs.WriteFile(flagPath, []byte(""), 0644)
227+}
228+
238229 func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
239230 url := fmt.Sprintf("%s/%s", h.rawURL, path)
240231 h.logger.Info("Downloading template", "url", url)
@@ -59,6 +59,16 @@ func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstal
59 h.logger.Info("Post-boot scripts prepared")59 h.logger.Info("Post-boot scripts prepared")
60 }60 }
61 61
62+ // Write Dank Linux flag file if user opted in
63+ if cmd.InstallDankLinux {
64+ if err := h.writeDankLinuxFlag(cmd.MountPoint); err != nil {
65+ result.ErrorDetail = fmt.Sprintf("Failed to write Dank Linux flag: %v", err)
66+ return result, fmt.Errorf("failed to write dank linux flag: %w", err)
67+ }
68+ result.TasksRun = append(result.TasksRun, "dank-linux-flag")
69+ h.logger.Info("Dank Linux flag file written")
70+ }
71+
62 // Install Plymouth theme if specified72 // Install Plymouth theme if specified
63 if cmd.PlymouthTheme != "" {73 if cmd.PlymouthTheme != "" {
64 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)74 h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)
@@ -120,14 +130,6 @@ func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, user
120 }130 }
121 }131 }
122 132
123- if err := h.copyShellConfigs(mountPoint, username); err != nil {
124- return fmt.Errorf("failed to copy shell configs: %w", err)
125- }
126-
127- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "chown", "-R", fmt.Sprintf("%s:%s", username, username), filepath.Join("/home", username, ".config")); err != nil {
128- return fmt.Errorf("failed to set shell config ownership: %w", err)
129- }
130-
131 if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", config.PostBootServiceName); err != nil {133 if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", config.PostBootServiceName); err != nil {
132 return fmt.Errorf("failed to enable first-boot service: %w", err)134 return fmt.Errorf("failed to enable first-boot service: %w", err)
133 }135 }
@@ -167,30 +169,9 @@ func (h *PostInstallHandler) installLimineLogo(mountPoint string) error {
167 return nil169 return nil
168 }170 }
169 171
172+// copyShellConfigs is a no-op: shell configuration is handled by
173+// cli-tools.sh on first boot. No config files are copied at install time.
170 func (h *PostInstallHandler) copyShellConfigs(mountPoint, username string) error {174 func (h *PostInstallHandler) copyShellConfigs(mountPoint, username string) error {
171- configDir := filepath.Join(mountPoint, "home", username, ".config")
172- if err := h.fs.MkdirAll(configDir, 0755); err != nil {
173- return fmt.Errorf("failed to create user config dir: %w", err)
174- }
175-
176- files := []string{
177- "install/configs/shell/aliases",
178- "install/configs/shell/envs",
179- "install/configs/shell/init",
180- "install/configs/shell/rc",
181- "install/configs/shell/shell",
182- "install/configs/shell/starship.toml",
183- "install/configs/shell/bashrc",
184- }
185-
186- for _, src := range files {
187- _, name := filepath.Split(src)
188- dst := filepath.Join(configDir, name)
189- if err := h.writeFromTemplate(src, dst); err != nil {
190- return err
191- }
192- }
193-
194 return nil175 return nil
195 }176 }
196 177
@@ -235,6 +216,16 @@ func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
235 return nil, fmt.Errorf("local template not found")216 return nil, fmt.Errorf("local template not found")
236 }217 }
237 218
219+// writeDankLinuxFlag writes a flag file to the installed system so that
220+// dms-opt-in.sh auto-runs without prompting on first boot.
221+func (h *PostInstallHandler) writeDankLinuxFlag(mountPoint string) error {
222+ if err := h.fs.MkdirAll(filepath.Join(mountPoint, "var", "lib"), 0755); err != nil {
223+ return fmt.Errorf("failed to create /var/lib: %w", err)
224+ }
225+ flagPath := filepath.Join(mountPoint, "var", "lib", "archup-install-danklinux")
226+ return h.fs.WriteFile(flagPath, []byte(""), 0644)
227+}
228+
238 func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {229 func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
239 url := fmt.Sprintf("%s/%s", h.rawURL, path)230 url := fmt.Sprintf("%s/%s", h.rawURL, path)
240 h.logger.Info("Downloading template", "url", url)231 h.logger.Info("Downloading template", "url", url)
M internal/application/handlers/post_install_handler_test.go +50 -0
@@ -162,6 +162,56 @@ func TestPostInstallHandler_Handle_WithPlymouth(t *testing.T) {
162162 }
163163 }
164164
165+func TestPostInstallHandler_Handle_WithDankLinux(t *testing.T) {
166+ ctrl := gomock.NewController(t)
167+ defer ctrl.Finish()
168+
169+ mockFS := mocks.NewMockFileSystem(ctrl)
170+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
171+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
172+ mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
173+ mockLogger := mocks.NewMockLogger(ctrl)
174+
175+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
176+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
177+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
178+ mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
179+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
180+ mockFS.EXPECT().WriteFile(gomock.Eq("/mnt/var/lib/archup-install-danklinux"), gomock.Any(), gomock.Any()).Return(nil)
181+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
182+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
183+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
184+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
185+
186+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
187+
188+ cmd := commands.PostInstallCommand{
189+ MountPoint: "/mnt",
190+ Username: "testuser",
191+ RunPostBootScripts: false,
192+ PlymouthTheme: "",
193+ InstallDankLinux: true,
194+ }
195+
196+ result, err := handler.Handle(context.Background(), cmd)
197+
198+ if err != nil {
199+ t.Fatalf("expected no error, got %v", err)
200+ }
201+
202+ if !result.Success {
203+ t.Error("expected success")
204+ }
205+
206+ if len(result.TasksRun) != 1 {
207+ t.Errorf("expected 1 task run, got %d", len(result.TasksRun))
208+ }
209+
210+ if result.TasksRun[0] != "dank-linux-flag" {
211+ t.Errorf("expected task 'dank-linux-flag', got %s", result.TasksRun[0])
212+ }
213+}
214+
165215 func TestPostInstallHandler_Handle_Everything(t *testing.T) {
166216 ctrl := gomock.NewController(t)
167217 defer ctrl.Finish()
@@ -162,6 +162,56 @@ func TestPostInstallHandler_Handle_WithPlymouth(t *testing.T) {
162 }162 }
163 }163 }
164 164
165+func TestPostInstallHandler_Handle_WithDankLinux(t *testing.T) {
166+ ctrl := gomock.NewController(t)
167+ defer ctrl.Finish()
168+
169+ mockFS := mocks.NewMockFileSystem(ctrl)
170+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
171+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
172+ mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
173+ mockLogger := mocks.NewMockLogger(ctrl)
174+
175+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
176+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
177+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
178+ mockFS.EXPECT().Exists(gomock.Any()).Return(false, nil).AnyTimes()
179+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("graphics: yes"), nil).AnyTimes()
180+ mockFS.EXPECT().WriteFile(gomock.Eq("/mnt/var/lib/archup-install-danklinux"), gomock.Any(), gomock.Any()).Return(nil)
181+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
182+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
183+ mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
184+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
185+
186+ handler := NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
187+
188+ cmd := commands.PostInstallCommand{
189+ MountPoint: "/mnt",
190+ Username: "testuser",
191+ RunPostBootScripts: false,
192+ PlymouthTheme: "",
193+ InstallDankLinux: true,
194+ }
195+
196+ result, err := handler.Handle(context.Background(), cmd)
197+
198+ if err != nil {
199+ t.Fatalf("expected no error, got %v", err)
200+ }
201+
202+ if !result.Success {
203+ t.Error("expected success")
204+ }
205+
206+ if len(result.TasksRun) != 1 {
207+ t.Errorf("expected 1 task run, got %d", len(result.TasksRun))
208+ }
209+
210+ if result.TasksRun[0] != "dank-linux-flag" {
211+ t.Errorf("expected task 'dank-linux-flag', got %s", result.TasksRun[0])
212+ }
213+}
214+
165 func TestPostInstallHandler_Handle_Everything(t *testing.T) {215 func TestPostInstallHandler_Handle_Everything(t *testing.T) {
166 ctrl := gomock.NewController(t)216 ctrl := gomock.NewController(t)
167 defer ctrl.Finish()217 defer ctrl.Finish()
M internal/config/config.go +2 -0
@@ -192,6 +192,8 @@ var PostBootScripts = []string{
192192 "ssh-keygen.sh",
193193 "archup-cli.sh",
194194 "blesh.sh",
195+ "cli-tools.sh",
196+ "dms-opt-in.sh",
195197 }
196198
197199 // Post-boot service template URL path
@@ -192,6 +192,8 @@ var PostBootScripts = []string{
192 "ssh-keygen.sh",192 "ssh-keygen.sh",
193 "archup-cli.sh",193 "archup-cli.sh",
194 "blesh.sh",194 "blesh.sh",
195+ "cli-tools.sh",
196+ "dms-opt-in.sh",
195 }197 }
196 198
197 // Post-boot service template URL path199 // Post-boot service template URL path
M internal/interfaces/tui/app.go +26 -1
@@ -33,6 +33,7 @@ type App struct {
3333 kernelModel *models.KernelModelImpl
3434 amdPstateModel *models.AMDPStateModelImpl
3535 gpuModel *models.GPUModelImpl
36+ dankLinuxModel *models.DankLinuxModelImpl
3637 installationModel *models.InstallationModelImpl
3738 progressModel *models.ProgressModelImpl
3839
@@ -57,6 +58,7 @@ const (
5758 ScreenKernel Screen = "kernel"
5859 ScreenAMDPState Screen = "amd-pstate"
5960 ScreenGPU Screen = "gpu"
61+ ScreenDankLinux Screen = "danklinux"
6062 ScreenInstalling Screen = "installing"
6163 ScreenProgress Screen = "progress"
6264 ScreenSummary Screen = "summary"
@@ -82,6 +84,7 @@ func NewApp(
8284 kernelModel: models.NewKernelModel(),
8385 amdPstateModel: models.NewAMDPStateModel(),
8486 gpuModel: models.NewGPUModel(),
87+ dankLinuxModel: models.NewDankLinuxModel(),
8588 installationModel: models.NewInstallationModel(),
8689 progressModel: models.NewProgressModel(),
8790 currentScreen: ScreenForm,
@@ -158,6 +161,8 @@ func (a *App) View() string {
158161 return views.RenderAMDPStateSelection(a.amdPstateModel)
159162 case ScreenGPU:
160163 return views.RenderGPUSelection(a.gpuModel)
164+ case ScreenDankLinux:
165+ return views.RenderDankLinuxSelection(a.dankLinuxModel)
161166 case ScreenInstalling, ScreenProgress:
162167 return views.RenderProgress(a.progressModel)
163168 case ScreenSummary:
@@ -182,6 +187,8 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
182187 return a.handleAMDPStateInput(msg)
183188 case ScreenGPU:
184189 return a.handleGPUInput(msg)
190+ case ScreenDankLinux:
191+ return a.handleDankLinuxInput(msg)
185192 case ScreenProgress:
186193 // Limited input during installation
187194 if msg.String() == "ctrl+c" {
@@ -310,12 +317,30 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
310317 // Update stored form data directly
311318 a.formData.GPUVendor = string(selected.Vendor)
312319 a.formData.GPUDrivers = append([]string{}, selected.Drivers...)
313- return a.startInstallation()
320+ a.currentScreen = ScreenDankLinux
321+ return a, nil
314322 }
315323
316324 return a, nil
317325 }
318326
327+func (a *App) handleDankLinuxInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
328+ switch msg.String() {
329+ case "up", "shift+tab":
330+ a.dankLinuxModel.MoveUp()
331+ return a, nil
332+ case "down", "tab":
333+ a.dankLinuxModel.MoveDown()
334+ return a, nil
335+ case "enter":
336+ a.formData.InstallDankLinux = a.dankLinuxModel.SelectedOption().Value
337+ return a.startInstallation()
338+ case "q", "ctrl+c":
339+ return a, tea.Quit
340+ }
341+ return a, nil
342+}
343+
319344 func (a *App) handleKernelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
320345 switch msg.String() {
321346 case "ctrl+c":
@@ -33,6 +33,7 @@ type App struct {
33 kernelModel *models.KernelModelImpl33 kernelModel *models.KernelModelImpl
34 amdPstateModel *models.AMDPStateModelImpl34 amdPstateModel *models.AMDPStateModelImpl
35 gpuModel *models.GPUModelImpl35 gpuModel *models.GPUModelImpl
36+ dankLinuxModel *models.DankLinuxModelImpl
36 installationModel *models.InstallationModelImpl37 installationModel *models.InstallationModelImpl
37 progressModel *models.ProgressModelImpl38 progressModel *models.ProgressModelImpl
38 39
@@ -57,6 +58,7 @@ const (
57 ScreenKernel Screen = "kernel"58 ScreenKernel Screen = "kernel"
58 ScreenAMDPState Screen = "amd-pstate"59 ScreenAMDPState Screen = "amd-pstate"
59 ScreenGPU Screen = "gpu"60 ScreenGPU Screen = "gpu"
61+ ScreenDankLinux Screen = "danklinux"
60 ScreenInstalling Screen = "installing"62 ScreenInstalling Screen = "installing"
61 ScreenProgress Screen = "progress"63 ScreenProgress Screen = "progress"
62 ScreenSummary Screen = "summary"64 ScreenSummary Screen = "summary"
@@ -82,6 +84,7 @@ func NewApp(
82 kernelModel: models.NewKernelModel(),84 kernelModel: models.NewKernelModel(),
83 amdPstateModel: models.NewAMDPStateModel(),85 amdPstateModel: models.NewAMDPStateModel(),
84 gpuModel: models.NewGPUModel(),86 gpuModel: models.NewGPUModel(),
87+ dankLinuxModel: models.NewDankLinuxModel(),
85 installationModel: models.NewInstallationModel(),88 installationModel: models.NewInstallationModel(),
86 progressModel: models.NewProgressModel(),89 progressModel: models.NewProgressModel(),
87 currentScreen: ScreenForm,90 currentScreen: ScreenForm,
@@ -158,6 +161,8 @@ func (a *App) View() string {
158 return views.RenderAMDPStateSelection(a.amdPstateModel)161 return views.RenderAMDPStateSelection(a.amdPstateModel)
159 case ScreenGPU:162 case ScreenGPU:
160 return views.RenderGPUSelection(a.gpuModel)163 return views.RenderGPUSelection(a.gpuModel)
164+ case ScreenDankLinux:
165+ return views.RenderDankLinuxSelection(a.dankLinuxModel)
161 case ScreenInstalling, ScreenProgress:166 case ScreenInstalling, ScreenProgress:
162 return views.RenderProgress(a.progressModel)167 return views.RenderProgress(a.progressModel)
163 case ScreenSummary:168 case ScreenSummary:
@@ -182,6 +187,8 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
182 return a.handleAMDPStateInput(msg)187 return a.handleAMDPStateInput(msg)
183 case ScreenGPU:188 case ScreenGPU:
184 return a.handleGPUInput(msg)189 return a.handleGPUInput(msg)
190+ case ScreenDankLinux:
191+ return a.handleDankLinuxInput(msg)
185 case ScreenProgress:192 case ScreenProgress:
186 // Limited input during installation193 // Limited input during installation
187 if msg.String() == "ctrl+c" {194 if msg.String() == "ctrl+c" {
@@ -310,12 +317,30 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
310 // Update stored form data directly317 // Update stored form data directly
311 a.formData.GPUVendor = string(selected.Vendor)318 a.formData.GPUVendor = string(selected.Vendor)
312 a.formData.GPUDrivers = append([]string{}, selected.Drivers...)319 a.formData.GPUDrivers = append([]string{}, selected.Drivers...)
313- return a.startInstallation()320+ a.currentScreen = ScreenDankLinux
321+ return a, nil
314 }322 }
315 323
316 return a, nil324 return a, nil
317 }325 }
318 326
327+func (a *App) handleDankLinuxInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
328+ switch msg.String() {
329+ case "up", "shift+tab":
330+ a.dankLinuxModel.MoveUp()
331+ return a, nil
332+ case "down", "tab":
333+ a.dankLinuxModel.MoveDown()
334+ return a, nil
335+ case "enter":
336+ a.formData.InstallDankLinux = a.dankLinuxModel.SelectedOption().Value
337+ return a.startInstallation()
338+ case "q", "ctrl+c":
339+ return a, tea.Quit
340+ }
341+ return a, nil
342+}
343+
319 func (a *App) handleKernelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {344 func (a *App) handleKernelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
320 switch msg.String() {345 switch msg.String() {
321 case "ctrl+c":346 case "ctrl+c":
M internal/interfaces/tui/handlers/install_handler.go +1 -0
@@ -213,6 +213,7 @@ func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd
213213 UserEmail: formData.UserEmail,
214214 PlymouthTheme: config.PlymouthThemeName,
215215 RunPostBootScripts: true,
216+ InstallDankLinux: formData.InstallDankLinux,
216217 }
217218 if _, err := svc.RunPostInstall(ctx, postCmd); err != nil {
218219 logger.Error("Post-installation failed", "error", err)
@@ -213,6 +213,7 @@ func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd
213 UserEmail: formData.UserEmail,213 UserEmail: formData.UserEmail,
214 PlymouthTheme: config.PlymouthThemeName,214 PlymouthTheme: config.PlymouthThemeName,
215 RunPostBootScripts: true,215 RunPostBootScripts: true,
216+ InstallDankLinux: formData.InstallDankLinux,
216 }217 }
217 if _, err := svc.RunPostInstall(ctx, postCmd); err != nil {218 if _, err := svc.RunPostInstall(ctx, postCmd); err != nil {
218 logger.Error("Post-installation failed", "error", err)219 logger.Error("Post-installation failed", "error", err)
A internal/interfaces/tui/models/dank_linux.go +72 -0
@@ -0,0 +1,72 @@
1+package models
2+
3+// DankLinuxOption represents a yes/no option for installing Dank Linux
4+type DankLinuxOption struct {
5+ Value bool
6+ Label string
7+ Description string
8+}
9+
10+// DankLinuxModelImpl is the model for the Dank Linux opt-in toggle
11+type DankLinuxModelImpl struct {
12+ options []DankLinuxOption
13+ selected int
14+}
15+
16+// NewDankLinuxModel creates a new Dank Linux opt-in selection model.
17+func NewDankLinuxModel() *DankLinuxModelImpl {
18+ return &DankLinuxModelImpl{
19+ options: []DankLinuxOption{
20+ {
21+ Value: false,
22+ Label: "No, keep barebone Bash",
23+ Description: "Barebone system with modern CLI tools. Install Dank Linux later with: curl -fsSL https://install.danklinux.com | sh",
24+ },
25+ {
26+ Value: true,
27+ Label: "Yes, install Dank Linux",
28+ Description: "Full Wayland desktop: niri or Hyprland + DankMaterialShell + Ghostty + auto-theming. Runs on first boot.",
29+ },
30+ },
31+ selected: 0,
32+ }
33+}
34+
35+// Options returns the selectable options.
36+func (d *DankLinuxModelImpl) Options() []DankLinuxOption {
37+ return d.options
38+}
39+
40+// SelectedIndex returns the current selection index.
41+func (d *DankLinuxModelImpl) SelectedIndex() int {
42+ return d.selected
43+}
44+
45+// SelectedOption returns the currently selected option.
46+func (d *DankLinuxModelImpl) SelectedOption() DankLinuxOption {
47+ if len(d.options) == 0 {
48+ return DankLinuxOption{}
49+ }
50+ if d.selected < 0 || d.selected >= len(d.options) {
51+ return d.options[0]
52+ }
53+ return d.options[d.selected]
54+}
55+
56+// MoveUp moves selection up, wrapping to the last option.
57+func (d *DankLinuxModelImpl) MoveUp() {
58+ if d.selected > 0 {
59+ d.selected--
60+ } else {
61+ d.selected = len(d.options) - 1
62+ }
63+}
64+
65+// MoveDown moves selection down, wrapping to the first option.
66+func (d *DankLinuxModelImpl) MoveDown() {
67+ if d.selected < len(d.options)-1 {
68+ d.selected++
69+ } else {
70+ d.selected = 0
71+ }
72+}
@@ -0,0 +1,72 @@
1+package models
2+
3+// DankLinuxOption represents a yes/no option for installing Dank Linux
4+type DankLinuxOption struct {
5+ Value bool
6+ Label string
7+ Description string
8+}
9+
10+// DankLinuxModelImpl is the model for the Dank Linux opt-in toggle
11+type DankLinuxModelImpl struct {
12+ options []DankLinuxOption
13+ selected int
14+}
15+
16+// NewDankLinuxModel creates a new Dank Linux opt-in selection model.
17+func NewDankLinuxModel() *DankLinuxModelImpl {
18+ return &DankLinuxModelImpl{
19+ options: []DankLinuxOption{
20+ {
21+ Value: false,
22+ Label: "No, keep barebone Bash",
23+ Description: "Barebone system with modern CLI tools. Install Dank Linux later with: curl -fsSL https://install.danklinux.com | sh",
24+ },
25+ {
26+ Value: true,
27+ Label: "Yes, install Dank Linux",
28+ Description: "Full Wayland desktop: niri or Hyprland + DankMaterialShell + Ghostty + auto-theming. Runs on first boot.",
29+ },
30+ },
31+ selected: 0,
32+ }
33+}
34+
35+// Options returns the selectable options.
36+func (d *DankLinuxModelImpl) Options() []DankLinuxOption {
37+ return d.options
38+}
39+
40+// SelectedIndex returns the current selection index.
41+func (d *DankLinuxModelImpl) SelectedIndex() int {
42+ return d.selected
43+}
44+
45+// SelectedOption returns the currently selected option.
46+func (d *DankLinuxModelImpl) SelectedOption() DankLinuxOption {
47+ if len(d.options) == 0 {
48+ return DankLinuxOption{}
49+ }
50+ if d.selected < 0 || d.selected >= len(d.options) {
51+ return d.options[0]
52+ }
53+ return d.options[d.selected]
54+}
55+
56+// MoveUp moves selection up, wrapping to the last option.
57+func (d *DankLinuxModelImpl) MoveUp() {
58+ if d.selected > 0 {
59+ d.selected--
60+ } else {
61+ d.selected = len(d.options) - 1
62+ }
63+}
64+
65+// MoveDown moves selection down, wrapping to the first option.
66+func (d *DankLinuxModelImpl) MoveDown() {
67+ if d.selected < len(d.options)-1 {
68+ d.selected++
69+ } else {
70+ d.selected = 0
71+ }
72+}
M internal/interfaces/tui/models/form.go +2 -0
@@ -39,6 +39,7 @@ type FormData struct {
3939 KernelVariant string
4040 AURHelper string
4141 Microcode bool
42+ InstallDankLinux bool
4243 }
4344
4445 // FormModelImpl implements FormModel interface
@@ -310,6 +311,7 @@ func (fm *FormModelImpl) extractData() {
310311 Timezone: fm.fields[4].Value(),
311312 Locale: fm.fields[5].Value(),
312313 Keymap: fm.fields[6].Value(),
314+ InstallDankLinux: fm.data.InstallDankLinux,
313315 }
314316 }
315317
@@ -39,6 +39,7 @@ type FormData struct {
39 KernelVariant string39 KernelVariant string
40 AURHelper string40 AURHelper string
41 Microcode bool41 Microcode bool
42+ InstallDankLinux bool
42 }43 }
43 44
44 // FormModelImpl implements FormModel interface45 // FormModelImpl implements FormModel interface
@@ -310,6 +311,7 @@ func (fm *FormModelImpl) extractData() {
310 Timezone: fm.fields[4].Value(),311 Timezone: fm.fields[4].Value(),
311 Locale: fm.fields[5].Value(),312 Locale: fm.fields[5].Value(),
312 Keymap: fm.fields[6].Value(),313 Keymap: fm.fields[6].Value(),
314+ InstallDankLinux: fm.data.InstallDankLinux,
313 }315 }
314 }316 }
315 317
A internal/interfaces/tui/views/dank_linux.go +50 -0
@@ -0,0 +1,50 @@
1+package views
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/interfaces/tui/models"
7+ "github.com/charmbracelet/lipgloss"
8+)
9+
10+// RenderDankLinuxSelection renders the Dank Linux opt-in screen.
11+func RenderDankLinuxSelection(dm *models.DankLinuxModelImpl) string {
12+ var b strings.Builder
13+
14+ title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15+ info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16+ active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17+ desc := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Faint(true)
18+
19+ b.WriteString("\n")
20+ b.WriteString(title.Render("Dank Linux Desktop"))
21+ b.WriteString("\n\n")
22+
23+ b.WriteString(info.Render("Optional: install Dank Linux — a full Wayland desktop suite."))
24+ b.WriteString("\n")
25+ b.WriteString(info.Render("Includes: niri or Hyprland, DankMaterialShell, Ghostty, and auto-theming."))
26+ b.WriteString("\n\n")
27+
28+ for i, option := range dm.Options() {
29+ prefix := " "
30+ style := lipgloss.NewStyle()
31+ labelStyle := lipgloss.NewStyle()
32+
33+ if i == dm.SelectedIndex() {
34+ prefix = "> "
35+ style = active
36+ labelStyle = active
37+ }
38+
39+ b.WriteString(style.Render(prefix))
40+ b.WriteString(labelStyle.Render(option.Label))
41+ b.WriteString("\n")
42+ b.WriteString(desc.Render(" " + option.Description))
43+ b.WriteString("\n")
44+ }
45+
46+ b.WriteString("\n")
47+ b.WriteString(info.Render("↑/↓ to select • enter to confirm"))
48+
49+ return b.String()
50+}
@@ -0,0 +1,50 @@
1+package views
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/interfaces/tui/models"
7+ "github.com/charmbracelet/lipgloss"
8+)
9+
10+// RenderDankLinuxSelection renders the Dank Linux opt-in screen.
11+func RenderDankLinuxSelection(dm *models.DankLinuxModelImpl) string {
12+ var b strings.Builder
13+
14+ title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15+ info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16+ active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17+ desc := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Faint(true)
18+
19+ b.WriteString("\n")
20+ b.WriteString(title.Render("Dank Linux Desktop"))
21+ b.WriteString("\n\n")
22+
23+ b.WriteString(info.Render("Optional: install Dank Linux — a full Wayland desktop suite."))
24+ b.WriteString("\n")
25+ b.WriteString(info.Render("Includes: niri or Hyprland, DankMaterialShell, Ghostty, and auto-theming."))
26+ b.WriteString("\n\n")
27+
28+ for i, option := range dm.Options() {
29+ prefix := " "
30+ style := lipgloss.NewStyle()
31+ labelStyle := lipgloss.NewStyle()
32+
33+ if i == dm.SelectedIndex() {
34+ prefix = "> "
35+ style = active
36+ labelStyle = active
37+ }
38+
39+ b.WriteString(style.Render(prefix))
40+ b.WriteString(labelStyle.Render(option.Label))
41+ b.WriteString("\n")
42+ b.WriteString(desc.Render(" " + option.Description))
43+ b.WriteString("\n")
44+ }
45+
46+ b.WriteString("\n")
47+ b.WriteString(info.Render("↑/↓ to select • enter to confirm"))
48+
49+ return b.String()
50+}
M internal/phases/postinstall.go +0 -102
@@ -484,37 +484,6 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
484484 return fmt.Errorf("failed to set ownership: %w", err)
485485 }
486486
487- // Clone bleu-theme repository
488- p.SendOutput(progressChan, "Cloning bleu-theme repository...")
489- themesDir := filepath.Join(userHome, ".local", "share", "archup", "themes")
490- bleuThemeDir := filepath.Join(themesDir, "bleu")
491- currentDir := filepath.Join(userHome, ".local", "share", "archup", "current")
492-
493- cloneCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && git clone https://github.com/bnema/bleu-theme.git %s'",
494- username, themesDir, bleuThemeDir)
495- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cloneCmd); {
496- case err != nil:
497- p.SendOutput(progressChan, "[WARN] Failed to clone bleu-theme repository")
498- default:
499- p.SendOutput(progressChan, "[OK] Bleu-theme repository cloned")
500- }
501-
502- // Create current theme symlink
503- symlinkCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && ln -snf %s %s/theme'",
504- username, currentDir, bleuThemeDir, currentDir)
505- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, symlinkCmd); {
506- case err != nil:
507- p.SendOutput(progressChan, "[WARN] Failed to create theme symlink")
508- }
509-
510- // Apply themes to CLI tools
511- p.SendOutput(progressChan, "Applying themes to CLI tools...")
512- switch err := p.applyThemes(progressChan, username, userHome); {
513- case err != nil:
514- // Non-fatal, log and continue
515- p.SendOutput(progressChan, "[WARN] Some themes failed to apply")
516- }
517-
518487 // Configure git delta
519488 gitCommands := []string{
520489 fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
@@ -546,7 +515,6 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
546515 config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
547516 config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
548517 config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
549- // Note: starship.toml is now copied from bleu-theme in applyThemes()
550518 }
551519
552520 for template, dest := range templates {
@@ -581,76 +549,6 @@ func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
581549 return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd)
582550 }
583551
584-// applyThemes applies bleu-theme to CLI tools
585-func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, username, userHome string) error {
586- themeDir := filepath.Join(userHome, ".local", "share", "archup", "current", "theme")
587- archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
588-
589- // Starship theme - copy to archup default location
590- p.SendOutput(progressChan, "Configuring starship theme...")
591- starshipCmd := fmt.Sprintf("su - %s -c 'cp %s/starship/bleu.toml %s/starship.toml'",
592- username, themeDir, archupDefault)
593- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, starshipCmd); err != nil {
594- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure starship theme: %v", err))
595- }
596-
597- // Eza theme
598- p.SendOutput(progressChan, "Configuring eza theme...")
599- ezaConfigDir := fmt.Sprintf("%s/.config/eza", userHome)
600- ezaCommands := []string{
601- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, ezaConfigDir),
602- fmt.Sprintf("su - %s -c 'cp %s/eza/theme.yml %s/'", username, themeDir, ezaConfigDir),
603- }
604- for _, cmd := range ezaCommands {
605- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
606- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure eza theme: %v", err))
607- }
608- }
609-
610- // Bat theme
611- p.SendOutput(progressChan, "Configuring bat theme...")
612- batThemeDir := fmt.Sprintf("%s/.config/bat/themes", userHome)
613- batCommands := []string{
614- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, batThemeDir),
615- fmt.Sprintf("su - %s -c 'cp %s/bat/bleu.tmTheme %s/'", username, themeDir, batThemeDir),
616- fmt.Sprintf("su - %s -c 'bat cache --build'", username),
617- }
618- for _, cmd := range batCommands {
619- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
620- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure bat theme: %v", err))
621- }
622- }
623-
624- // Btop theme
625- p.SendOutput(progressChan, "Configuring btop theme...")
626- btopThemeDir := fmt.Sprintf("%s/.config/btop/themes", userHome)
627- btopCommands := []string{
628- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, btopThemeDir),
629- fmt.Sprintf("su - %s -c 'cp %s/btop/bleu.theme %s/'", username, themeDir, btopThemeDir),
630- }
631- for _, cmd := range btopCommands {
632- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
633- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure btop theme: %v", err))
634- }
635- }
636-
637- // Yazi theme
638- p.SendOutput(progressChan, "Configuring yazi theme...")
639- yaziFlavorDir := fmt.Sprintf("%s/.config/yazi/flavors/bleu", userHome)
640- yaziCommands := []string{
641- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, yaziFlavorDir),
642- fmt.Sprintf("su - %s -c 'cp %s/yazi/bleu.toml %s/flavor.toml'", username, themeDir, yaziFlavorDir),
643- }
644- for _, cmd := range yaziCommands {
645- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
646- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure yazi theme: %v", err))
647- }
648- }
649-
650- p.SendOutput(progressChan, "[OK] CLI themes configured")
651- return nil
652-}
653-
654552 // verifyInstallation performs comprehensive verification
655553 func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {
656554 p.SendOutput(progressChan, "Verifying installation components...")
@@ -484,37 +484,6 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
484 return fmt.Errorf("failed to set ownership: %w", err)484 return fmt.Errorf("failed to set ownership: %w", err)
485 }485 }
486 486
487- // Clone bleu-theme repository
488- p.SendOutput(progressChan, "Cloning bleu-theme repository...")
489- themesDir := filepath.Join(userHome, ".local", "share", "archup", "themes")
490- bleuThemeDir := filepath.Join(themesDir, "bleu")
491- currentDir := filepath.Join(userHome, ".local", "share", "archup", "current")
492-
493- cloneCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && git clone https://github.com/bnema/bleu-theme.git %s'",
494- username, themesDir, bleuThemeDir)
495- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cloneCmd); {
496- case err != nil:
497- p.SendOutput(progressChan, "[WARN] Failed to clone bleu-theme repository")
498- default:
499- p.SendOutput(progressChan, "[OK] Bleu-theme repository cloned")
500- }
501-
502- // Create current theme symlink
503- symlinkCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && ln -snf %s %s/theme'",
504- username, currentDir, bleuThemeDir, currentDir)
505- switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, symlinkCmd); {
506- case err != nil:
507- p.SendOutput(progressChan, "[WARN] Failed to create theme symlink")
508- }
509-
510- // Apply themes to CLI tools
511- p.SendOutput(progressChan, "Applying themes to CLI tools...")
512- switch err := p.applyThemes(progressChan, username, userHome); {
513- case err != nil:
514- // Non-fatal, log and continue
515- p.SendOutput(progressChan, "[WARN] Some themes failed to apply")
516- }
517-
518 // Configure git delta487 // Configure git delta
519 gitCommands := []string{488 gitCommands := []string{
520 fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),489 fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
@@ -546,7 +515,6 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
546 config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),515 config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
547 config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),516 config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
548 config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),517 config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
549- // Note: starship.toml is now copied from bleu-theme in applyThemes()
550 }518 }
551 519
552 for template, dest := range templates {520 for template, dest := range templates {
@@ -581,76 +549,6 @@ func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
581 return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd)549 return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd)
582 }550 }
583 551
584-// applyThemes applies bleu-theme to CLI tools
585-func (p *PostInstallPhase) applyThemes(progressChan chan<- ProgressUpdate, username, userHome string) error {
586- themeDir := filepath.Join(userHome, ".local", "share", "archup", "current", "theme")
587- archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
588-
589- // Starship theme - copy to archup default location
590- p.SendOutput(progressChan, "Configuring starship theme...")
591- starshipCmd := fmt.Sprintf("su - %s -c 'cp %s/starship/bleu.toml %s/starship.toml'",
592- username, themeDir, archupDefault)
593- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, starshipCmd); err != nil {
594- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure starship theme: %v", err))
595- }
596-
597- // Eza theme
598- p.SendOutput(progressChan, "Configuring eza theme...")
599- ezaConfigDir := fmt.Sprintf("%s/.config/eza", userHome)
600- ezaCommands := []string{
601- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, ezaConfigDir),
602- fmt.Sprintf("su - %s -c 'cp %s/eza/theme.yml %s/'", username, themeDir, ezaConfigDir),
603- }
604- for _, cmd := range ezaCommands {
605- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
606- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure eza theme: %v", err))
607- }
608- }
609-
610- // Bat theme
611- p.SendOutput(progressChan, "Configuring bat theme...")
612- batThemeDir := fmt.Sprintf("%s/.config/bat/themes", userHome)
613- batCommands := []string{
614- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, batThemeDir),
615- fmt.Sprintf("su - %s -c 'cp %s/bat/bleu.tmTheme %s/'", username, themeDir, batThemeDir),
616- fmt.Sprintf("su - %s -c 'bat cache --build'", username),
617- }
618- for _, cmd := range batCommands {
619- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
620- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure bat theme: %v", err))
621- }
622- }
623-
624- // Btop theme
625- p.SendOutput(progressChan, "Configuring btop theme...")
626- btopThemeDir := fmt.Sprintf("%s/.config/btop/themes", userHome)
627- btopCommands := []string{
628- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, btopThemeDir),
629- fmt.Sprintf("su - %s -c 'cp %s/btop/bleu.theme %s/'", username, themeDir, btopThemeDir),
630- }
631- for _, cmd := range btopCommands {
632- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
633- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure btop theme: %v", err))
634- }
635- }
636-
637- // Yazi theme
638- p.SendOutput(progressChan, "Configuring yazi theme...")
639- yaziFlavorDir := fmt.Sprintf("%s/.config/yazi/flavors/bleu", userHome)
640- yaziCommands := []string{
641- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, yaziFlavorDir),
642- fmt.Sprintf("su - %s -c 'cp %s/yazi/bleu.toml %s/flavor.toml'", username, themeDir, yaziFlavorDir),
643- }
644- for _, cmd := range yaziCommands {
645- if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, cmd); err != nil {
646- p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to configure yazi theme: %v", err))
647- }
648- }
649-
650- p.SendOutput(progressChan, "[OK] CLI themes configured")
651- return nil
652-}
653-
654 // verifyInstallation performs comprehensive verification552 // verifyInstallation performs comprehensive verification
655 func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {553 func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {
656 p.SendOutput(progressChan, "Verifying installation components...")554 p.SendOutput(progressChan, "Verifying installation components...")
M internal/shell/configurator.go +7 -149
@@ -1,15 +1,5 @@
11 package shell
22
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10- "github.com/bnema/archup/internal/logger"
11-)
12-
133 // ConfigResult holds the result of shell configuration
144 type ConfigResult struct {
155 ThemesApplied int
@@ -18,150 +8,18 @@ type ConfigResult struct {
188 }
199
2010 // Configurator handles shell and bash configuration setup
21-type Configurator struct {
22- fs interfaces.FileSystem
23- http interfaces.HTTPClient
24- chrExec interfaces.ChrootExecutor
25- config *config.Config
26- logger *logger.Logger
27-}
11+type Configurator struct{}
2812
2913 // NewConfigurator creates a new shell configurator
30-func NewConfigurator(
31- fs interfaces.FileSystem,
32- http interfaces.HTTPClient,
33- chrExec interfaces.ChrootExecutor,
34- cfg *config.Config,
35- log *logger.Logger,
36-) *Configurator {
37- return &Configurator{
38- fs: fs,
39- http: http,
40- chrExec: chrExec,
41- config: cfg,
42- logger: log,
43- }
14+func NewConfigurator() *Configurator {
15+ return &Configurator{}
4416 }
4517
46-// Configure sets up shell and CLI tool theming for a user
18+// Configure is a no-op. Shell configuration is handled by cli-tools.sh at first boot.
4719 func (c *Configurator) Configure(username, userHome string) (ConfigResult, error) {
48- result := ConfigResult{
20+ return ConfigResult{
4921 ThemesApplied: 0,
5022 ThemesFailed: []string{},
51- Warnings: []string{},
52- }
53-
54- // Create archup shell directory structure
55- archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
56- archupDefaultBash := filepath.Join(archupDefault, "bash")
57-
58- if err := c.fs.MkdirAll(archupDefaultBash, 0755); err != nil {
59- return result, fmt.Errorf("failed to create shell config directory: %w", err)
60- }
61-
62- // Copy shell configuration files from templates
63- if err := c.copyShellTemplates(archupDefault, archupDefaultBash); err != nil {
64- return result, fmt.Errorf("failed to copy shell templates: %w", err)
65- }
66-
67- // Create .bashrc
68- bashrcContent, err := c.readTemplate(config.BashrcTemplate)
69- if err != nil {
70- return result, fmt.Errorf("failed to read bashrc template: %w", err)
71- }
72-
73- bashrcPath := filepath.Join(userHome, ".bashrc")
74- if err := c.fs.WriteFile(bashrcPath, bashrcContent, 0644); err != nil {
75- return result, fmt.Errorf("failed to write .bashrc: %w", err)
76- }
77-
78- // Set ownership to user
79- if err := c.setShellOwnership(userHome, username); err != nil {
80- return result, fmt.Errorf("failed to set ownership: %w", err)
81- }
82-
83- // Clone bleu-theme repository
84- relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
85- themesDir := filepath.Join(relativeHome, ".local", "share", "archup", "themes")
86- bleuThemeDir := filepath.Join(themesDir, "bleu")
87- currentDir := filepath.Join(relativeHome, ".local", "share", "archup", "current")
88-
89- cloneCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && git clone https://github.com/bnema/bleu-theme.git %s'",
90- username, themesDir, bleuThemeDir)
91- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cloneCmd); err != nil {
92- result.Warnings = append(result.Warnings, "Failed to clone bleu-theme repository")
93- }
94-
95- // Create current theme symlink
96- symlinkCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && ln -snf %s %s/theme'",
97- username, currentDir, bleuThemeDir, currentDir)
98- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, symlinkCmd); err != nil {
99- result.Warnings = append(result.Warnings, "Failed to create theme symlink")
100- }
101-
102- // Apply themes to CLI tools
103- themeResult := c.applyThemes(username, userHome)
104- result.ThemesApplied = themeResult.Applied
105- result.ThemesFailed = themeResult.Failed
106- result.Warnings = append(result.Warnings, themeResult.Warnings...)
107-
108- // Configure git delta
109- gitCommands := []string{
110- fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
111- fmt.Sprintf("su - %s -c 'git config --global interactive.diffFilter \"delta --color-only\"'", username),
112- fmt.Sprintf("su - %s -c 'git config --global delta.navigate true'", username),
113- fmt.Sprintf("su - %s -c 'git config --global delta.light false'", username),
114- fmt.Sprintf("su - %s -c 'git config --global delta.side-by-side true'", username),
115- fmt.Sprintf("su - %s -c 'git config --global merge.conflictstyle diff3'", username),
116- fmt.Sprintf("su - %s -c 'git config --global diff.colorMoved default'", username),
117- }
118-
119- for _, gitCmd := range gitCommands {
120- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, gitCmd); err != nil {
121- result.Warnings = append(result.Warnings, "Failed to configure git delta")
122- break
123- }
124- }
125-
126- return result, nil
127-}
128-
129-// copyShellTemplates copies shell config templates to user directory
130-func (c *Configurator) copyShellTemplates(archupDefault, archupDefaultBash string) error {
131- templates := map[string]string{
132- config.ShellConfigTemplate: filepath.Join(archupDefaultBash, "shell"),
133- config.ShellInitTemplate: filepath.Join(archupDefaultBash, "init"),
134- config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
135- config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
136- config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
137- }
138-
139- for template, dest := range templates {
140- content, err := c.readTemplate(template)
141- if err != nil {
142- return fmt.Errorf("failed to read template %s: %w", template, err)
143- }
144-
145- if err := c.fs.WriteFile(dest, content, 0644); err != nil {
146- return fmt.Errorf("failed to write %s: %w", dest, err)
147- }
148- }
149-
150- return nil
151-}
152-
153-// readTemplate reads a template file from install path
154-func (c *Configurator) readTemplate(filename string) ([]byte, error) {
155- // Use DefaultInstallDir directly to match where bootstrap downloads files
156- templatePath := filepath.Join(config.DefaultInstallDir, filename)
157- return c.fs.ReadFile(templatePath)
158-}
159-
160-// setShellOwnership sets ownership of shell config files to user
161-func (c *Configurator) setShellOwnership(userHome, username string) error {
162- // Use chroot to run chown
163- relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
164- chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc",
165- username, username, relativeHome, relativeHome)
166- return c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, chownCmd)
23+ Warnings: []string{"Shell configuration deferred to cli-tools.sh on first boot"},
24+ }, nil
16725 }
@@ -1,15 +1,5 @@
1 package shell1 package shell
2 2
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10- "github.com/bnema/archup/internal/logger"
11-)
12-
13 // ConfigResult holds the result of shell configuration3 // ConfigResult holds the result of shell configuration
14 type ConfigResult struct {4 type ConfigResult struct {
15 ThemesApplied int5 ThemesApplied int
@@ -18,150 +8,18 @@ type ConfigResult struct {
18 }8 }
19 9
20 // Configurator handles shell and bash configuration setup10 // Configurator handles shell and bash configuration setup
21-type Configurator struct {11+type Configurator struct{}
22- fs interfaces.FileSystem
23- http interfaces.HTTPClient
24- chrExec interfaces.ChrootExecutor
25- config *config.Config
26- logger *logger.Logger
27-}
28 12
29 // NewConfigurator creates a new shell configurator13 // NewConfigurator creates a new shell configurator
30-func NewConfigurator(14+func NewConfigurator() *Configurator {
31- fs interfaces.FileSystem,15+ return &Configurator{}
32- http interfaces.HTTPClient,
33- chrExec interfaces.ChrootExecutor,
34- cfg *config.Config,
35- log *logger.Logger,
36-) *Configurator {
37- return &Configurator{
38- fs: fs,
39- http: http,
40- chrExec: chrExec,
41- config: cfg,
42- logger: log,
43- }
44 }16 }
45 17
46-// Configure sets up shell and CLI tool theming for a user18+// Configure is a no-op. Shell configuration is handled by cli-tools.sh at first boot.
47 func (c *Configurator) Configure(username, userHome string) (ConfigResult, error) {19 func (c *Configurator) Configure(username, userHome string) (ConfigResult, error) {
48- result := ConfigResult{20+ return ConfigResult{
49 ThemesApplied: 0,21 ThemesApplied: 0,
50 ThemesFailed: []string{},22 ThemesFailed: []string{},
51- Warnings: []string{},23+ Warnings: []string{"Shell configuration deferred to cli-tools.sh on first boot"},
52- }24+ }, nil
53-
54- // Create archup shell directory structure
55- archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
56- archupDefaultBash := filepath.Join(archupDefault, "bash")
57-
58- if err := c.fs.MkdirAll(archupDefaultBash, 0755); err != nil {
59- return result, fmt.Errorf("failed to create shell config directory: %w", err)
60- }
61-
62- // Copy shell configuration files from templates
63- if err := c.copyShellTemplates(archupDefault, archupDefaultBash); err != nil {
64- return result, fmt.Errorf("failed to copy shell templates: %w", err)
65- }
66-
67- // Create .bashrc
68- bashrcContent, err := c.readTemplate(config.BashrcTemplate)
69- if err != nil {
70- return result, fmt.Errorf("failed to read bashrc template: %w", err)
71- }
72-
73- bashrcPath := filepath.Join(userHome, ".bashrc")
74- if err := c.fs.WriteFile(bashrcPath, bashrcContent, 0644); err != nil {
75- return result, fmt.Errorf("failed to write .bashrc: %w", err)
76- }
77-
78- // Set ownership to user
79- if err := c.setShellOwnership(userHome, username); err != nil {
80- return result, fmt.Errorf("failed to set ownership: %w", err)
81- }
82-
83- // Clone bleu-theme repository
84- relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
85- themesDir := filepath.Join(relativeHome, ".local", "share", "archup", "themes")
86- bleuThemeDir := filepath.Join(themesDir, "bleu")
87- currentDir := filepath.Join(relativeHome, ".local", "share", "archup", "current")
88-
89- cloneCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && git clone https://github.com/bnema/bleu-theme.git %s'",
90- username, themesDir, bleuThemeDir)
91- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cloneCmd); err != nil {
92- result.Warnings = append(result.Warnings, "Failed to clone bleu-theme repository")
93- }
94-
95- // Create current theme symlink
96- symlinkCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s && ln -snf %s %s/theme'",
97- username, currentDir, bleuThemeDir, currentDir)
98- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, symlinkCmd); err != nil {
99- result.Warnings = append(result.Warnings, "Failed to create theme symlink")
100- }
101-
102- // Apply themes to CLI tools
103- themeResult := c.applyThemes(username, userHome)
104- result.ThemesApplied = themeResult.Applied
105- result.ThemesFailed = themeResult.Failed
106- result.Warnings = append(result.Warnings, themeResult.Warnings...)
107-
108- // Configure git delta
109- gitCommands := []string{
110- fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
111- fmt.Sprintf("su - %s -c 'git config --global interactive.diffFilter \"delta --color-only\"'", username),
112- fmt.Sprintf("su - %s -c 'git config --global delta.navigate true'", username),
113- fmt.Sprintf("su - %s -c 'git config --global delta.light false'", username),
114- fmt.Sprintf("su - %s -c 'git config --global delta.side-by-side true'", username),
115- fmt.Sprintf("su - %s -c 'git config --global merge.conflictstyle diff3'", username),
116- fmt.Sprintf("su - %s -c 'git config --global diff.colorMoved default'", username),
117- }
118-
119- for _, gitCmd := range gitCommands {
120- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, gitCmd); err != nil {
121- result.Warnings = append(result.Warnings, "Failed to configure git delta")
122- break
123- }
124- }
125-
126- return result, nil
127-}
128-
129-// copyShellTemplates copies shell config templates to user directory
130-func (c *Configurator) copyShellTemplates(archupDefault, archupDefaultBash string) error {
131- templates := map[string]string{
132- config.ShellConfigTemplate: filepath.Join(archupDefaultBash, "shell"),
133- config.ShellInitTemplate: filepath.Join(archupDefaultBash, "init"),
134- config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
135- config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
136- config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
137- }
138-
139- for template, dest := range templates {
140- content, err := c.readTemplate(template)
141- if err != nil {
142- return fmt.Errorf("failed to read template %s: %w", template, err)
143- }
144-
145- if err := c.fs.WriteFile(dest, content, 0644); err != nil {
146- return fmt.Errorf("failed to write %s: %w", dest, err)
147- }
148- }
149-
150- return nil
151-}
152-
153-// readTemplate reads a template file from install path
154-func (c *Configurator) readTemplate(filename string) ([]byte, error) {
155- // Use DefaultInstallDir directly to match where bootstrap downloads files
156- templatePath := filepath.Join(config.DefaultInstallDir, filename)
157- return c.fs.ReadFile(templatePath)
158-}
159-
160-// setShellOwnership sets ownership of shell config files to user
161-func (c *Configurator) setShellOwnership(userHome, username string) error {
162- // Use chroot to run chown
163- relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
164- chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc",
165- username, username, relativeHome, relativeHome)
166- return c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, chownCmd)
167 }25 }
D internal/shell/themes.go +0 -210
@@ -1,210 +0,0 @@
1-package shell
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9-)
10-
11-// ThemeResult holds the result of theme application
12-type ThemeResult struct {
13- Applied int
14- Failed []string
15- Warnings []string
16-}
17-
18-// applyThemes orchestrates centralized theme symlink setup for all CLI tools
19-func (c *Configurator) applyThemes(username, userHome string) ThemeResult {
20- result := ThemeResult{
21- Applied: 0,
22- Failed: []string{},
23- Warnings: []string{},
24- }
25-
26- // Strip /mnt prefix for use in chroot commands
27- relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
28-
29- // Step 1: Setup central theme directory structure
30- if err := c.setupCentralThemeDirectory(username, relativeHome); err != nil {
31- result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to setup central theme directory: %v", err))
32- return result
33- }
34-
35- // Step 2: Create central symlinks pointing to active theme (bleu)
36- if err := c.createCentralSymlinks(username, relativeHome, "bleu"); err != nil {
37- result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to create central symlinks: %v", err))
38- return result
39- }
40-
41- // Step 3: Apply each tool's theme configuration
42- tools := []struct {
43- name string
44- apply func(string, string) error
45- }{
46- {"starship", c.applyStarshipTheme},
47- {"eza", c.applyEzaTheme},
48- {"bat", c.applyBatTheme},
49- {"btop", c.applyBtopTheme},
50- {"yazi", c.applyYaziTheme},
51- }
52-
53- for _, tool := range tools {
54- if err := tool.apply(username, relativeHome); err != nil {
55- result.Failed = append(result.Failed, tool.name)
56- result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to configure %s: %v", tool.name, err))
57- } else {
58- result.Applied++
59- }
60- }
61-
62- return result
63-}
64-
65-// setupCentralThemeDirectory creates ~/.local/share/archup/themes/current/
66-func (c *Configurator) setupCentralThemeDirectory(username, relativeHome string) error {
67- centralThemeDir := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current")
68-
69- mkdirCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, centralThemeDir)
70- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, mkdirCmd); err != nil {
71- return fmt.Errorf("failed to create central theme directory: %w", err)
72- }
73-
74- return nil
75-}
76-
77-// createCentralSymlinks creates symlinks in themes/current/ pointing to active theme files
78-func (c *Configurator) createCentralSymlinks(username, relativeHome, activeTheme string) error {
79- themesDir := filepath.Join(relativeHome, ".local", "share", "archup", "themes")
80- centralDir := filepath.Join(themesDir, "current")
81- themeSource := filepath.Join(themesDir, activeTheme)
82-
83- // Map of symlink name -> target path
84- symlinks := map[string]string{
85- "starship.toml": filepath.Join(themeSource, "starship", "bleu.toml"),
86- "eza.yml": filepath.Join(themeSource, "eza", "theme.yml"),
87- "bat.tmTheme": filepath.Join(themeSource, "bat", "bleu.tmTheme"),
88- "btop.theme": filepath.Join(themeSource, "btop", "bleu.theme"),
89- "yazi.toml": filepath.Join(themeSource, "yazi", "bleu.toml"),
90- "fzf.sh": filepath.Join(themeSource, "fzf", "bleu.sh"),
91- }
92-
93- for linkName, target := range symlinks {
94- linkPath := filepath.Join(centralDir, linkName)
95-
96- // Use ln -snf: -s for symbolic, -n to handle existing symlink as file, -f to force overwrite
97- symlinkCmd := fmt.Sprintf("su - %s -c 'ln -snf %s %s'",
98- username, target, linkPath)
99-
100- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, symlinkCmd); err != nil {
101- return fmt.Errorf("failed to create symlink %s -> %s: %w", linkName, target, err)
102- }
103- }
104-
105- return nil
106-}
107-
108-// applyStarshipTheme configures starship to use central symlink
109-// Note: Actual configuration happens via STARSHIP_CONFIG env var in configs/shell/init
110-func (c *Configurator) applyStarshipTheme(username, relativeHome string) error {
111- // Starship uses STARSHIP_CONFIG environment variable pointing to themes/current/starship.toml
112- // No additional setup needed - the symlink is already created by createCentralSymlinks()
113- // Shell config will be updated separately to export STARSHIP_CONFIG
114- return nil
115-}
116-
117-// applyEzaTheme creates symlink in eza config directory pointing to central theme
118-func (c *Configurator) applyEzaTheme(username, relativeHome string) error {
119- ezaConfigDir := filepath.Join(relativeHome, ".config", "eza")
120- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "eza.yml")
121- ezaThemeLink := filepath.Join(ezaConfigDir, "theme.yml")
122-
123- commands := []string{
124- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, ezaConfigDir),
125- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, ezaThemeLink),
126- }
127-
128- for _, cmd := range commands {
129- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
130- return fmt.Errorf("eza theme setup failed: %w", err)
131- }
132- }
133-
134- return nil
135-}
136-
137-// applyBatTheme creates symlink in bat themes directory and configures bat.conf
138-func (c *Configurator) applyBatTheme(username, relativeHome string) error {
139- batThemesDir := filepath.Join(relativeHome, ".config", "bat", "themes")
140- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "bat.tmTheme")
141- batThemeLink := filepath.Join(batThemesDir, "current.tmTheme")
142-
143- commands := []string{
144- // Create bat themes directory
145- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, batThemesDir),
146-
147- // Create symlink: ~/.config/bat/themes/current.tmTheme -> themes/current/bat.tmTheme
148- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, batThemeLink),
149-
150- // Create bat config referencing "current" theme
151- fmt.Sprintf("su - %s -c 'echo \"--theme=\\\"current\\\"\" > ~/.config/bat/bat.conf'", username),
152-
153- // Build bat cache to register the theme
154- fmt.Sprintf("su - %s -c 'bat cache --build'", username),
155- }
156-
157- for _, cmd := range commands {
158- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
159- return fmt.Errorf("bat theme setup failed: %w", err)
160- }
161- }
162-
163- return nil
164-}
165-
166-// applyBtopTheme creates symlink in btop themes directory
167-func (c *Configurator) applyBtopTheme(username, relativeHome string) error {
168- btopThemesDir := filepath.Join(relativeHome, ".config", "btop", "themes")
169- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "btop.theme")
170- btopThemeLink := filepath.Join(btopThemesDir, "current.theme")
171-
172- commands := []string{
173- // Create btop themes directory
174- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, btopThemesDir),
175-
176- // Create symlink: ~/.config/btop/themes/current.theme -> themes/current/btop.theme
177- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, btopThemeLink),
178- }
179-
180- for _, cmd := range commands {
181- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
182- return fmt.Errorf("btop theme setup failed: %w", err)
183- }
184- }
185-
186- return nil
187-}
188-
189-// applyYaziTheme creates yazi flavor directory with symlink to central theme
190-func (c *Configurator) applyYaziTheme(username, relativeHome string) error {
191- yaziFlavorDir := filepath.Join(relativeHome, ".config", "yazi", "flavors", "current")
192- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "yazi.toml")
193- yaziFlavorLink := filepath.Join(yaziFlavorDir, "flavor.toml")
194-
195- commands := []string{
196- // Create yazi flavor directory for "current" theme
197- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, yaziFlavorDir),
198-
199- // Create symlink: ~/.config/yazi/flavors/current/flavor.toml -> themes/current/yazi.toml
200- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, yaziFlavorLink),
201- }
202-
203- for _, cmd := range commands {
204- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
205- return fmt.Errorf("yazi theme setup failed: %w", err)
206- }
207- }
208-
209- return nil
210-}
@@ -1,210 +0,0 @@
1-package shell
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9-)
10-
11-// ThemeResult holds the result of theme application
12-type ThemeResult struct {
13- Applied int
14- Failed []string
15- Warnings []string
16-}
17-
18-// applyThemes orchestrates centralized theme symlink setup for all CLI tools
19-func (c *Configurator) applyThemes(username, userHome string) ThemeResult {
20- result := ThemeResult{
21- Applied: 0,
22- Failed: []string{},
23- Warnings: []string{},
24- }
25-
26- // Strip /mnt prefix for use in chroot commands
27- relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
28-
29- // Step 1: Setup central theme directory structure
30- if err := c.setupCentralThemeDirectory(username, relativeHome); err != nil {
31- result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to setup central theme directory: %v", err))
32- return result
33- }
34-
35- // Step 2: Create central symlinks pointing to active theme (bleu)
36- if err := c.createCentralSymlinks(username, relativeHome, "bleu"); err != nil {
37- result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to create central symlinks: %v", err))
38- return result
39- }
40-
41- // Step 3: Apply each tool's theme configuration
42- tools := []struct {
43- name string
44- apply func(string, string) error
45- }{
46- {"starship", c.applyStarshipTheme},
47- {"eza", c.applyEzaTheme},
48- {"bat", c.applyBatTheme},
49- {"btop", c.applyBtopTheme},
50- {"yazi", c.applyYaziTheme},
51- }
52-
53- for _, tool := range tools {
54- if err := tool.apply(username, relativeHome); err != nil {
55- result.Failed = append(result.Failed, tool.name)
56- result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to configure %s: %v", tool.name, err))
57- } else {
58- result.Applied++
59- }
60- }
61-
62- return result
63-}
64-
65-// setupCentralThemeDirectory creates ~/.local/share/archup/themes/current/
66-func (c *Configurator) setupCentralThemeDirectory(username, relativeHome string) error {
67- centralThemeDir := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current")
68-
69- mkdirCmd := fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, centralThemeDir)
70- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, mkdirCmd); err != nil {
71- return fmt.Errorf("failed to create central theme directory: %w", err)
72- }
73-
74- return nil
75-}
76-
77-// createCentralSymlinks creates symlinks in themes/current/ pointing to active theme files
78-func (c *Configurator) createCentralSymlinks(username, relativeHome, activeTheme string) error {
79- themesDir := filepath.Join(relativeHome, ".local", "share", "archup", "themes")
80- centralDir := filepath.Join(themesDir, "current")
81- themeSource := filepath.Join(themesDir, activeTheme)
82-
83- // Map of symlink name -> target path
84- symlinks := map[string]string{
85- "starship.toml": filepath.Join(themeSource, "starship", "bleu.toml"),
86- "eza.yml": filepath.Join(themeSource, "eza", "theme.yml"),
87- "bat.tmTheme": filepath.Join(themeSource, "bat", "bleu.tmTheme"),
88- "btop.theme": filepath.Join(themeSource, "btop", "bleu.theme"),
89- "yazi.toml": filepath.Join(themeSource, "yazi", "bleu.toml"),
90- "fzf.sh": filepath.Join(themeSource, "fzf", "bleu.sh"),
91- }
92-
93- for linkName, target := range symlinks {
94- linkPath := filepath.Join(centralDir, linkName)
95-
96- // Use ln -snf: -s for symbolic, -n to handle existing symlink as file, -f to force overwrite
97- symlinkCmd := fmt.Sprintf("su - %s -c 'ln -snf %s %s'",
98- username, target, linkPath)
99-
100- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, symlinkCmd); err != nil {
101- return fmt.Errorf("failed to create symlink %s -> %s: %w", linkName, target, err)
102- }
103- }
104-
105- return nil
106-}
107-
108-// applyStarshipTheme configures starship to use central symlink
109-// Note: Actual configuration happens via STARSHIP_CONFIG env var in configs/shell/init
110-func (c *Configurator) applyStarshipTheme(username, relativeHome string) error {
111- // Starship uses STARSHIP_CONFIG environment variable pointing to themes/current/starship.toml
112- // No additional setup needed - the symlink is already created by createCentralSymlinks()
113- // Shell config will be updated separately to export STARSHIP_CONFIG
114- return nil
115-}
116-
117-// applyEzaTheme creates symlink in eza config directory pointing to central theme
118-func (c *Configurator) applyEzaTheme(username, relativeHome string) error {
119- ezaConfigDir := filepath.Join(relativeHome, ".config", "eza")
120- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "eza.yml")
121- ezaThemeLink := filepath.Join(ezaConfigDir, "theme.yml")
122-
123- commands := []string{
124- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, ezaConfigDir),
125- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, ezaThemeLink),
126- }
127-
128- for _, cmd := range commands {
129- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
130- return fmt.Errorf("eza theme setup failed: %w", err)
131- }
132- }
133-
134- return nil
135-}
136-
137-// applyBatTheme creates symlink in bat themes directory and configures bat.conf
138-func (c *Configurator) applyBatTheme(username, relativeHome string) error {
139- batThemesDir := filepath.Join(relativeHome, ".config", "bat", "themes")
140- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "bat.tmTheme")
141- batThemeLink := filepath.Join(batThemesDir, "current.tmTheme")
142-
143- commands := []string{
144- // Create bat themes directory
145- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, batThemesDir),
146-
147- // Create symlink: ~/.config/bat/themes/current.tmTheme -> themes/current/bat.tmTheme
148- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, batThemeLink),
149-
150- // Create bat config referencing "current" theme
151- fmt.Sprintf("su - %s -c 'echo \"--theme=\\\"current\\\"\" > ~/.config/bat/bat.conf'", username),
152-
153- // Build bat cache to register the theme
154- fmt.Sprintf("su - %s -c 'bat cache --build'", username),
155- }
156-
157- for _, cmd := range commands {
158- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
159- return fmt.Errorf("bat theme setup failed: %w", err)
160- }
161- }
162-
163- return nil
164-}
165-
166-// applyBtopTheme creates symlink in btop themes directory
167-func (c *Configurator) applyBtopTheme(username, relativeHome string) error {
168- btopThemesDir := filepath.Join(relativeHome, ".config", "btop", "themes")
169- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "btop.theme")
170- btopThemeLink := filepath.Join(btopThemesDir, "current.theme")
171-
172- commands := []string{
173- // Create btop themes directory
174- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, btopThemesDir),
175-
176- // Create symlink: ~/.config/btop/themes/current.theme -> themes/current/btop.theme
177- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, btopThemeLink),
178- }
179-
180- for _, cmd := range commands {
181- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
182- return fmt.Errorf("btop theme setup failed: %w", err)
183- }
184- }
185-
186- return nil
187-}
188-
189-// applyYaziTheme creates yazi flavor directory with symlink to central theme
190-func (c *Configurator) applyYaziTheme(username, relativeHome string) error {
191- yaziFlavorDir := filepath.Join(relativeHome, ".config", "yazi", "flavors", "current")
192- centralTheme := filepath.Join(relativeHome, ".local", "share", "archup", "themes", "current", "yazi.toml")
193- yaziFlavorLink := filepath.Join(yaziFlavorDir, "flavor.toml")
194-
195- commands := []string{
196- // Create yazi flavor directory for "current" theme
197- fmt.Sprintf("su - %s -c 'mkdir -p %s'", username, yaziFlavorDir),
198-
199- // Create symlink: ~/.config/yazi/flavors/current/flavor.toml -> themes/current/yazi.toml
200- fmt.Sprintf("su - %s -c 'ln -snf %s %s'", username, centralTheme, yaziFlavorLink),
201- }
202-
203- for _, cmd := range commands {
204- if err := c.chrExec.ChrootExec(c.logger.LogPath(), config.PathMnt, cmd); err != nil {
205- return fmt.Errorf("yazi theme setup failed: %w", err)
206- }
207- }
208-
209- return nil
210-}
D internal/verify/themes.go +0 -173
@@ -1,173 +0,0 @@
1-package verify
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10-)
11-
12-// ThemeVerification holds results of theme verification
13-type ThemeVerification struct {
14- BleuThemeCloned bool
15- ThemeFilesOK map[string]bool // Tool name -> exists
16- ConfigFilesOK map[string]bool // Tool name -> config file exists
17- BatCacheBuilt bool
18- StarshipValid bool
19- Warnings []string
20- Errors []string
21-}
22-
23-// NewThemeVerification creates a new theme verification result
24-func NewThemeVerification() *ThemeVerification {
25- return &ThemeVerification{
26- ThemeFilesOK: make(map[string]bool),
27- ConfigFilesOK: make(map[string]bool),
28- Warnings: []string{},
29- Errors: []string{},
30- }
31-}
32-
33-// ValidateBleuThemeClone verifies the bleu-theme repository was successfully cloned
34-func ValidateBleuThemeClone(fs interfaces.FileSystem, userHome string) bool {
35- bleuThemeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
36- gitDir := filepath.Join(bleuThemeDir, ".git")
37-
38- // Check if .git directory exists (indicates successful clone)
39- _, err := fs.Stat(gitDir)
40- return err == nil
41-}
42-
43-// ValidateThemeFiles checks if expected theme files exist after cloning
44-func ValidateThemeFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
45- result := NewThemeVerification()
46- themeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
47-
48- // Check clone first
49- result.BleuThemeCloned = ValidateBleuThemeClone(fs, userHome)
50- if !result.BleuThemeCloned {
51- result.Warnings = append(result.Warnings, "bleu-theme not cloned successfully")
52- return result
53- }
54-
55- // Theme file paths to check
56- themeFiles := map[string]string{
57- "starship": filepath.Join(themeDir, "starship", "bleu.toml"),
58- "eza": filepath.Join(themeDir, "eza", "theme.yml"),
59- "bat": filepath.Join(themeDir, "bat", "bleu.tmTheme"),
60- "btop": filepath.Join(themeDir, "btop", "bleu.theme"),
61- "yazi": filepath.Join(themeDir, "yazi", "bleu.toml"),
62- "fzf": filepath.Join(themeDir, "fzf", "bleu.sh"),
63- }
64-
65- for tool, path := range themeFiles {
66- _, err := fs.Stat(path)
67- exists := err == nil
68- result.ThemeFilesOK[tool] = exists
69- if !exists {
70- result.Warnings = append(result.Warnings, fmt.Sprintf("%s theme file missing: %s", tool, path))
71- }
72- }
73-
74- return result
75-}
76-
77-// ValidateConfigFiles checks if configuration files were successfully copied
78-func ValidateConfigFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
79- result := NewThemeVerification()
80-
81- configFiles := map[string]string{
82- "starship": filepath.Join(userHome, ".local", "share", "archup", "default", "starship.toml"),
83- "eza": filepath.Join(userHome, ".config", "eza", "theme.yml"),
84- "bat": filepath.Join(userHome, ".config", "bat", "themes", "bleu.tmTheme"),
85- "btop": filepath.Join(userHome, ".config", "btop", "themes", "bleu.theme"),
86- "yazi": filepath.Join(userHome, ".config", "yazi", "flavors", "bleu", "flavor.toml"),
87- }
88-
89- for tool, path := range configFiles {
90- _, err := fs.Stat(path)
91- exists := err == nil
92- result.ConfigFilesOK[tool] = exists
93- if !exists {
94- result.Warnings = append(result.Warnings, fmt.Sprintf("%s config not found: %s", tool, path))
95- }
96- }
97-
98- return result
99-}
100-
101-// ValidateBatCache checks if bat cache was built successfully
102-// This would require running `bat --list-themes` in chroot
103-func ValidateBatCache(chrExec interfaces.ChrootExecutor, logPath string) bool {
104- cmd := "bat --list-themes | grep -i bleu"
105- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
106- return err == nil
107-}
108-
109-// ValidateStarshipConfig tests if starship can load its configuration
110-func ValidateStarshipConfig(chrExec interfaces.ChrootExecutor, logPath string) bool {
111- cmd := "starship config"
112- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
113- return err == nil
114-}
115-
116-// GetThemeVerificationSummary returns a formatted summary of theme verification
117-func GetThemeVerificationSummary(result *ThemeVerification) string {
118- var summary []string
119-
120- if result.BleuThemeCloned {
121- summary = append(summary, "[OK] Bleu-theme repository cloned")
122- } else {
123- summary = append(summary, "[WARN] Bleu-theme repository not cloned")
124- }
125-
126- // Theme files summary
127- missingThemes := []string{}
128- for tool, ok := range result.ThemeFilesOK {
129- if !ok {
130- missingThemes = append(missingThemes, tool)
131- }
132- }
133- if len(missingThemes) > 0 {
134- summary = append(summary, fmt.Sprintf("[WARN] Missing theme files: %s", strings.Join(missingThemes, ", ")))
135- } else if len(result.ThemeFilesOK) > 0 {
136- summary = append(summary, "[OK] All theme files found")
137- }
138-
139- // Config files summary
140- missingConfigs := []string{}
141- for tool, ok := range result.ConfigFilesOK {
142- if !ok {
143- missingConfigs = append(missingConfigs, tool)
144- }
145- }
146- if len(missingConfigs) > 0 {
147- summary = append(summary, fmt.Sprintf("[WARN] Missing config files: %s", strings.Join(missingConfigs, ", ")))
148- } else if len(result.ConfigFilesOK) > 0 {
149- summary = append(summary, "[OK] All config files found")
150- }
151-
152- // Bat cache
153- if result.BatCacheBuilt {
154- summary = append(summary, "[OK] Bat cache built successfully")
155- }
156-
157- // Starship config
158- if result.StarshipValid {
159- summary = append(summary, "[OK] Starship config valid")
160- }
161-
162- // Warnings
163- for _, w := range result.Warnings {
164- summary = append(summary, fmt.Sprintf("[WARN] %s", w))
165- }
166-
167- // Errors
168- for _, e := range result.Errors {
169- summary = append(summary, fmt.Sprintf("[ERROR] %s", e))
170- }
171-
172- return strings.Join(summary, "\n")
173-}
@@ -1,173 +0,0 @@
1-package verify
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "github.com/bnema/archup/internal/config"
9- "github.com/bnema/archup/internal/interfaces"
10-)
11-
12-// ThemeVerification holds results of theme verification
13-type ThemeVerification struct {
14- BleuThemeCloned bool
15- ThemeFilesOK map[string]bool // Tool name -> exists
16- ConfigFilesOK map[string]bool // Tool name -> config file exists
17- BatCacheBuilt bool
18- StarshipValid bool
19- Warnings []string
20- Errors []string
21-}
22-
23-// NewThemeVerification creates a new theme verification result
24-func NewThemeVerification() *ThemeVerification {
25- return &ThemeVerification{
26- ThemeFilesOK: make(map[string]bool),
27- ConfigFilesOK: make(map[string]bool),
28- Warnings: []string{},
29- Errors: []string{},
30- }
31-}
32-
33-// ValidateBleuThemeClone verifies the bleu-theme repository was successfully cloned
34-func ValidateBleuThemeClone(fs interfaces.FileSystem, userHome string) bool {
35- bleuThemeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
36- gitDir := filepath.Join(bleuThemeDir, ".git")
37-
38- // Check if .git directory exists (indicates successful clone)
39- _, err := fs.Stat(gitDir)
40- return err == nil
41-}
42-
43-// ValidateThemeFiles checks if expected theme files exist after cloning
44-func ValidateThemeFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
45- result := NewThemeVerification()
46- themeDir := filepath.Join(userHome, ".local", "share", "archup", "themes", "bleu")
47-
48- // Check clone first
49- result.BleuThemeCloned = ValidateBleuThemeClone(fs, userHome)
50- if !result.BleuThemeCloned {
51- result.Warnings = append(result.Warnings, "bleu-theme not cloned successfully")
52- return result
53- }
54-
55- // Theme file paths to check
56- themeFiles := map[string]string{
57- "starship": filepath.Join(themeDir, "starship", "bleu.toml"),
58- "eza": filepath.Join(themeDir, "eza", "theme.yml"),
59- "bat": filepath.Join(themeDir, "bat", "bleu.tmTheme"),
60- "btop": filepath.Join(themeDir, "btop", "bleu.theme"),
61- "yazi": filepath.Join(themeDir, "yazi", "bleu.toml"),
62- "fzf": filepath.Join(themeDir, "fzf", "bleu.sh"),
63- }
64-
65- for tool, path := range themeFiles {
66- _, err := fs.Stat(path)
67- exists := err == nil
68- result.ThemeFilesOK[tool] = exists
69- if !exists {
70- result.Warnings = append(result.Warnings, fmt.Sprintf("%s theme file missing: %s", tool, path))
71- }
72- }
73-
74- return result
75-}
76-
77-// ValidateConfigFiles checks if configuration files were successfully copied
78-func ValidateConfigFiles(fs interfaces.FileSystem, userHome string) *ThemeVerification {
79- result := NewThemeVerification()
80-
81- configFiles := map[string]string{
82- "starship": filepath.Join(userHome, ".local", "share", "archup", "default", "starship.toml"),
83- "eza": filepath.Join(userHome, ".config", "eza", "theme.yml"),
84- "bat": filepath.Join(userHome, ".config", "bat", "themes", "bleu.tmTheme"),
85- "btop": filepath.Join(userHome, ".config", "btop", "themes", "bleu.theme"),
86- "yazi": filepath.Join(userHome, ".config", "yazi", "flavors", "bleu", "flavor.toml"),
87- }
88-
89- for tool, path := range configFiles {
90- _, err := fs.Stat(path)
91- exists := err == nil
92- result.ConfigFilesOK[tool] = exists
93- if !exists {
94- result.Warnings = append(result.Warnings, fmt.Sprintf("%s config not found: %s", tool, path))
95- }
96- }
97-
98- return result
99-}
100-
101-// ValidateBatCache checks if bat cache was built successfully
102-// This would require running `bat --list-themes` in chroot
103-func ValidateBatCache(chrExec interfaces.ChrootExecutor, logPath string) bool {
104- cmd := "bat --list-themes | grep -i bleu"
105- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
106- return err == nil
107-}
108-
109-// ValidateStarshipConfig tests if starship can load its configuration
110-func ValidateStarshipConfig(chrExec interfaces.ChrootExecutor, logPath string) bool {
111- cmd := "starship config"
112- err := chrExec.ChrootExec(logPath, config.PathMnt, cmd)
113- return err == nil
114-}
115-
116-// GetThemeVerificationSummary returns a formatted summary of theme verification
117-func GetThemeVerificationSummary(result *ThemeVerification) string {
118- var summary []string
119-
120- if result.BleuThemeCloned {
121- summary = append(summary, "[OK] Bleu-theme repository cloned")
122- } else {
123- summary = append(summary, "[WARN] Bleu-theme repository not cloned")
124- }
125-
126- // Theme files summary
127- missingThemes := []string{}
128- for tool, ok := range result.ThemeFilesOK {
129- if !ok {
130- missingThemes = append(missingThemes, tool)
131- }
132- }
133- if len(missingThemes) > 0 {
134- summary = append(summary, fmt.Sprintf("[WARN] Missing theme files: %s", strings.Join(missingThemes, ", ")))
135- } else if len(result.ThemeFilesOK) > 0 {
136- summary = append(summary, "[OK] All theme files found")
137- }
138-
139- // Config files summary
140- missingConfigs := []string{}
141- for tool, ok := range result.ConfigFilesOK {
142- if !ok {
143- missingConfigs = append(missingConfigs, tool)
144- }
145- }
146- if len(missingConfigs) > 0 {
147- summary = append(summary, fmt.Sprintf("[WARN] Missing config files: %s", strings.Join(missingConfigs, ", ")))
148- } else if len(result.ConfigFilesOK) > 0 {
149- summary = append(summary, "[OK] All config files found")
150- }
151-
152- // Bat cache
153- if result.BatCacheBuilt {
154- summary = append(summary, "[OK] Bat cache built successfully")
155- }
156-
157- // Starship config
158- if result.StarshipValid {
159- summary = append(summary, "[OK] Starship config valid")
160- }
161-
162- // Warnings
163- for _, w := range result.Warnings {
164- summary = append(summary, fmt.Sprintf("[WARN] %s", w))
165- }
166-
167- // Errors
168- for _, e := range result.Errors {
169- summary = append(summary, fmt.Sprintf("[ERROR] %s", e))
170- }
171-
172- return strings.Join(summary, "\n")
173-}
D internal/wizard/application/services/wizard_service.go +0 -814
@@ -1,814 +0,0 @@
1-package services
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "os"
8- osuser "os/user"
9- "path/filepath"
10- "strings"
11-
12- "github.com/bnema/archup/internal/defaults"
13- "github.com/bnema/archup/internal/domain/ports"
14- "github.com/bnema/archup/internal/wizard/domain"
15-)
16-
17-// WizardService orchestrates the desktop setup wizard flow.
18-type WizardService struct {
19- fs ports.FileSystem
20- cmdExec ports.CommandExecutor
21- logger ports.Logger
22-}
23-
24-// NewWizardService creates a new wizard service.
25-func NewWizardService(fs ports.FileSystem, cmdExec ports.CommandExecutor, logger ports.Logger) *WizardService {
26- return &WizardService{
27- fs: fs,
28- cmdExec: cmdExec,
29- logger: logger,
30- }
31-}
32-
33-// Start begins the wizard run.
34-func (s *WizardService) Start(ctx context.Context, config domain.DesktopConfig) error {
35- return s.Install(ctx, config)
36-}
37-
38-// Install performs package installation and theme setup.
39-func (s *WizardService) Install(ctx context.Context, config domain.DesktopConfig) error {
40- s.logger.Info("Wizard install", "compositor", config.Compositor)
41- packages, err := s.buildPackageList(config)
42- if err != nil {
43- return err
44- }
45- if err := s.installPackages(ctx, packages); err != nil {
46- return err
47- }
48- if err := s.ensureBleuTheme(ctx); err != nil {
49- return err
50- }
51- return nil
52-}
53-
54-// ApplyConfig writes compositor configuration.
55-func (s *WizardService) ApplyConfig(config domain.DesktopConfig) error {
56- return s.ApplyConfigWithMonitors(config, nil)
57-}
58-
59-// ApplyConfigWithMonitors writes compositor configuration and monitor layout.
60-func (s *WizardService) ApplyConfigWithMonitors(config domain.DesktopConfig, monitors []MonitorConfig) error {
61- if err := s.applyCompositorConfig(config); err != nil {
62- return err
63- }
64- if err := s.ensureHyprlockConfig(); err != nil {
65- return err
66- }
67- if err := s.ensureHypridleConfig(); err != nil {
68- return err
69- }
70- if len(monitors) > 0 {
71- if err := s.writeMonitorConfig(config, monitors); err != nil {
72- return err
73- }
74- }
75- if config.EnableSDDM {
76- if err := s.ensureSDDMTheme(); err != nil {
77- return err
78- }
79- if err := s.ensureSDDMConfig(config); err != nil {
80- return err
81- }
82- if err := s.enableSDDMService(); err != nil {
83- return err
84- }
85- }
86- if err := s.ensureWaybarConfig(); err != nil {
87- return err
88- }
89- if err := s.applyGtkTheme(); err != nil {
90- return err
91- }
92- if err := s.enableUserServices(); err != nil {
93- return err
94- }
95- return nil
96-}
97-
98-func (s *WizardService) buildPackageList(config domain.DesktopConfig) ([]string, error) {
99- packages := []string{}
100-
101- core, err := defaults.ReadPackageList("desktop-core")
102- if err != nil {
103- return nil, err
104- }
105- packages = append(packages, core...)
106-
107- video, err := defaults.ReadPackageList("video-accel")
108- if err != nil {
109- return nil, err
110- }
111- packages = append(packages, video...)
112-
113- gtk, err := defaults.ReadPackageList("gtk")
114- if err != nil {
115- return nil, err
116- }
117- packages = append(packages, gtk...)
118-
119- if config.Compositor == domain.CompositorNiri {
120- compositor, err := defaults.ReadPackageList("compositor-niri")
121- if err != nil {
122- return nil, err
123- }
124- packages = append(packages, compositor...)
125- }
126-
127- if config.Compositor == domain.CompositorHyprland {
128- compositor, err := defaults.ReadPackageList("compositor-hyprland")
129- if err != nil {
130- return nil, err
131- }
132- packages = append(packages, compositor...)
133- }
134-
135- if config.EnableSDDM {
136- sddm, err := defaults.ReadPackageList("sddm")
137- if err != nil {
138- return nil, err
139- }
140- packages = append(packages, sddm...)
141- }
142-
143- if config.InstallCliphist {
144- packages = append(packages, "cliphist")
145- }
146-
147- return uniquePackages(packages), nil
148-}
149-
150-func (s *WizardService) installPackages(ctx context.Context, packages []string) error {
151- if len(packages) == 0 {
152- s.logger.Info("No packages to install")
153- return nil
154- }
155-
156- args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
157- s.logger.Info("Installing desktop packages", "count", len(packages))
158- if _, err := s.cmdExec.Execute(ctx, "pacman", args...); err != nil {
159- return fmt.Errorf("install packages: %w", err)
160- }
161-
162- return nil
163-}
164-
165-func (s *WizardService) applyCompositorConfig(config domain.DesktopConfig) error {
166- home, err := os.UserHomeDir()
167- if err != nil {
168- return fmt.Errorf("resolve home directory: %w", err)
169- }
170-
171- switch config.Compositor {
172- case domain.CompositorHyprland:
173- return s.applyHyprlandConfig(home, config)
174- case domain.CompositorNiri:
175- return s.applyNiriConfig(home, config)
176- default:
177- return nil
178- }
179-}
180-
181-func (s *WizardService) ensureBleuTheme(ctx context.Context) error {
182- home, err := os.UserHomeDir()
183- if err != nil {
184- return fmt.Errorf("resolve home directory: %w", err)
185- }
186-
187- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
188- exists, err := s.fs.Exists(themeDir)
189- if err != nil {
190- return fmt.Errorf("check bleu theme directory: %w", err)
191- }
192- if !exists {
193- if err := s.fs.MkdirAll(filepath.Dir(themeDir), 0o755); err != nil {
194- return fmt.Errorf("create theme parent directory: %w", err)
195- }
196- if _, err := s.cmdExec.Execute(ctx, "git", "clone", "https://github.com/bnema/bleu-theme", themeDir); err != nil {
197- return fmt.Errorf("clone bleu-theme: %w", err)
198- }
199- }
200-
201- wallpaperSource := filepath.Join(themeDir, "wallpapers", "wp-bleu-1.png")
202- wallpaperTarget := filepath.Join(home, "Pictures", "Wallpapers", "wp-bleu-1.png")
203- if err := s.fs.MkdirAll(filepath.Dir(wallpaperTarget), 0o755); err != nil {
204- return fmt.Errorf("create wallpaper directory: %w", err)
205- }
206- data, err := s.fs.ReadFile(wallpaperSource)
207- if err != nil {
208- return fmt.Errorf("read wallpaper: %w", err)
209- }
210- if err := s.fs.WriteFile(wallpaperTarget, data, 0o644); err != nil {
211- return fmt.Errorf("write wallpaper: %w", err)
212- }
213-
214- return nil
215-}
216-
217-func (s *WizardService) writeMonitorConfig(config domain.DesktopConfig, monitors []MonitorConfig) error {
218- home, err := os.UserHomeDir()
219- if err != nil {
220- return fmt.Errorf("resolve home directory: %w", err)
221- }
222-
223- switch config.Compositor {
224- case domain.CompositorHyprland:
225- return s.writeHyprlandMonitorConfig(home, monitors)
226- case domain.CompositorNiri:
227- return s.writeNiriMonitorConfig(home, monitors)
228- default:
229- return nil
230- }
231-}
232-
233-func (s *WizardService) writeHyprlandMonitorConfig(home string, monitors []MonitorConfig) error {
234- configDir := filepath.Join(home, ".config", "hypr")
235- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
236- return fmt.Errorf("create hypr config dir: %w", err)
237- }
238-
239- lines := []string{"# ArchUp monitors"}
240- for _, monitor := range monitors {
241- if !monitor.Enabled {
242- lines = append(lines, fmt.Sprintf("monitor = %s,disable", monitor.Name))
243- continue
244- }
245- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
246- pos := fmt.Sprintf("%dx%d", monitor.PosX, monitor.PosY)
247- scale := fmt.Sprintf("%.2f", monitor.Scale)
248- lines = append(lines, fmt.Sprintf("monitor = %s,%s,%s,%s", monitor.Name, mode, pos, scale))
249- }
250- lines = append(lines, "")
251-
252- path := filepath.Join(configDir, "archup-monitors.conf")
253- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
254- return fmt.Errorf("write hyprland monitor config: %w", err)
255- }
256-
257- includeLine := "source = ~/.config/hypr/archup-monitors.conf"
258- mainPath := filepath.Join(configDir, "hyprland.conf")
259- contents, err := s.readFileIfExists(mainPath)
260- if err != nil {
261- return err
262- }
263- if !containsLine(contents, includeLine) {
264- contents = appendLine(contents, includeLine)
265- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
266- return fmt.Errorf("write hyprland config: %w", err)
267- }
268- }
269-
270- return nil
271-}
272-
273-func (s *WizardService) writeNiriMonitorConfig(home string, monitors []MonitorConfig) error {
274- configDir := filepath.Join(home, ".config", "niri")
275- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
276- return fmt.Errorf("create niri config dir: %w", err)
277- }
278-
279- lines := []string{"// ArchUp monitors"}
280- for _, monitor := range monitors {
281- if !monitor.Enabled {
282- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
283- lines = append(lines, " off")
284- lines = append(lines, "}")
285- lines = append(lines, "")
286- continue
287- }
288- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
289- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
290- lines = append(lines, fmt.Sprintf(" mode \"%s\"", mode))
291- lines = append(lines, fmt.Sprintf(" scale %.2f", monitor.Scale))
292- lines = append(lines, fmt.Sprintf(" position x=%d y=%d", monitor.PosX, monitor.PosY))
293- lines = append(lines, "}")
294- lines = append(lines, "")
295- }
296-
297- path := filepath.Join(configDir, "archup-monitors.kdl")
298- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
299- return fmt.Errorf("write niri monitor config: %w", err)
300- }
301-
302- includeLine := "include \"archup-monitors.kdl\""
303- mainPath := filepath.Join(configDir, "config.kdl")
304- contents, err := s.readFileIfExists(mainPath)
305- if err != nil {
306- return err
307- }
308- if !containsLine(contents, includeLine) {
309- contents = appendLine(contents, includeLine)
310- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
311- return fmt.Errorf("write niri config: %w", err)
312- }
313- }
314-
315- return nil
316-}
317-
318-func (s *WizardService) ensureHyprlockConfig() error {
319- home, err := os.UserHomeDir()
320- if err != nil {
321- return fmt.Errorf("resolve home directory: %w", err)
322- }
323-
324- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
325- configDir := filepath.Join(home, ".config", "hypr")
326- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
327- return fmt.Errorf("create hypr config dir: %w", err)
328- }
329-
330- source := filepath.Join(themeDir, "hyprlock", "hyprlock.conf")
331- target := filepath.Join(configDir, "hyprlock.conf")
332- data, err := s.fs.ReadFile(source)
333- if err != nil {
334- return fmt.Errorf("read hyprlock config: %w", err)
335- }
336- if err := s.writeIfChanged(target, data, 0o644); err != nil {
337- return fmt.Errorf("write hyprlock config: %w", err)
338- }
339-
340- return nil
341-}
342-
343-func (s *WizardService) ensureHypridleConfig() error {
344- home, err := os.UserHomeDir()
345- if err != nil {
346- return fmt.Errorf("resolve home directory: %w", err)
347- }
348-
349- configDir := filepath.Join(home, ".config", "hypr")
350- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
351- return fmt.Errorf("create hypr config dir: %w", err)
352- }
353-
354- configPath := filepath.Join(configDir, "hypridle.conf")
355- content := strings.Join([]string{
356- "general {",
357- " lock_cmd = hyprlock",
358- " before_sleep_cmd = loginctl lock-session",
359- " after_sleep_cmd = notify-send \"Welcome back\"",
360- "}",
361- "",
362- "listener {",
363- " timeout = 600",
364- " on-timeout = hyprlock",
365- " on-resume = notify-send \"Unlocked\"",
366- "}",
367- "",
368- }, "\n")
369-
370- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
371- return fmt.Errorf("write hypridle config: %w", err)
372- }
373-
374- return nil
375-}
376-
377-func (s *WizardService) ensureSDDMTheme() error {
378- home, err := os.UserHomeDir()
379- if err != nil {
380- return fmt.Errorf("resolve home directory: %w", err)
381- }
382-
383- source := filepath.Join(home, ".config", "themes", "bleu-theme", "sddm", "bleu-terminal")
384- if exists, err := s.fs.Exists(source); err != nil {
385- return fmt.Errorf("check sddm theme source: %w", err)
386- } else if !exists {
387- return fmt.Errorf("sddm theme source not found: %s", source)
388- }
389-
390- target := "/usr/share/sddm/themes/bleu-terminal"
391- if _, err := s.cmdExec.Execute(context.Background(), "cp", "-r", source, target); err != nil {
392- return fmt.Errorf("copy sddm theme: %w", err)
393- }
394-
395- return nil
396-}
397-
398-func (s *WizardService) ensureSDDMConfig(config domain.DesktopConfig) error {
399- configDir := "/etc/sddm.conf.d"
400- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
401- return fmt.Errorf("create sddm config dir: %w", err)
402- }
403-
404- lines := []string{
405- "[Theme]",
406- "Current=bleu-terminal",
407- }
408-
409- if config.AutoLogin {
410- userName := resolveLoginUser()
411- if userName != "" {
412- lines = append(lines, "")
413- lines = append(lines, "[Autologin]")
414- lines = append(lines, fmt.Sprintf("User=%s", userName))
415- lines = append(lines, fmt.Sprintf("Session=%s", sddmSessionName(config.Compositor)))
416- }
417- }
418-
419- content := strings.Join(lines, "\n") + "\n"
420- configPath := filepath.Join(configDir, "archup.conf")
421- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
422- return fmt.Errorf("write sddm config: %w", err)
423- }
424-
425- return nil
426-}
427-
428-func (s *WizardService) enableSDDMService() error {
429- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "enable", "--now", "sddm"); err != nil {
430- return fmt.Errorf("enable sddm service: %w", err)
431- }
432- return nil
433-}
434-
435-func resolveLoginUser() string {
436- if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
437- return sudoUser
438- }
439- current, err := osuser.Current()
440- if err != nil {
441- return ""
442- }
443- return current.Username
444-}
445-
446-func sddmSessionName(compositor domain.Compositor) string {
447- if compositor == domain.CompositorHyprland {
448- return "hyprland"
449- }
450- return "niri"
451-}
452-
453-func (s *WizardService) ensureWaybarConfig() error {
454- home, err := os.UserHomeDir()
455- if err != nil {
456- return fmt.Errorf("resolve home directory: %w", err)
457- }
458-
459- configDir := filepath.Join(home, ".config", "waybar")
460- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
461- return fmt.Errorf("create waybar config dir: %w", err)
462- }
463-
464- configPath := filepath.Join(configDir, "config")
465- templatePath := filepath.Join("install", "configs", "waybar", "config.json")
466- configData, err := s.fs.ReadFile(templatePath)
467- if err != nil {
468- return fmt.Errorf("read waybar config template: %w", err)
469- }
470- if err := s.writeIfChanged(configPath, configData, 0o644); err != nil {
471- return fmt.Errorf("write waybar config: %w", err)
472- }
473-
474- stylePath := filepath.Join(configDir, "style.css")
475- source := filepath.Join(home, ".config", "themes", "bleu-theme", "waybar", "bleu.css")
476- data, err := s.fs.ReadFile(source)
477- if err != nil {
478- return fmt.Errorf("read waybar style: %w", err)
479- }
480- if err := s.writeIfChanged(stylePath, data, 0o644); err != nil {
481- return fmt.Errorf("write waybar style: %w", err)
482- }
483-
484- scriptDir := filepath.Join(configDir, "scripts")
485- if err := s.fs.MkdirAll(scriptDir, 0o755); err != nil {
486- return fmt.Errorf("create waybar scripts dir: %w", err)
487- }
488- scriptPath := filepath.Join(scriptDir, "theme-toggle.sh")
489- scriptSource := filepath.Join("install", "configs", "waybar", "theme-toggle.sh")
490- scriptData, err := s.fs.ReadFile(scriptSource)
491- if err != nil {
492- return fmt.Errorf("read waybar theme toggle script: %w", err)
493- }
494- if err := s.writeIfChanged(scriptPath, scriptData, 0o755); err != nil {
495- return fmt.Errorf("write waybar theme toggle script: %w", err)
496- }
497-
498- return nil
499-}
500-
501-func (s *WizardService) writeIfChanged(path string, data []byte, perm os.FileMode) error {
502- exists, err := s.fs.Exists(path)
503- if err != nil {
504- return err
505- }
506- if exists {
507- existing, err := s.fs.ReadFile(path)
508- if err != nil {
509- return err
510- }
511- if string(existing) == string(data) {
512- return nil
513- }
514- }
515- return s.fs.WriteFile(path, data, perm)
516-}
517-
518-func (s *WizardService) applyGtkTheme() error {
519- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"); err != nil {
520- return fmt.Errorf("set GTK color scheme: %w", err)
521- }
522- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "adw-gtk3-dark"); err != nil {
523- return fmt.Errorf("set GTK theme: %w", err)
524- }
525- return nil
526-}
527-
528-func (s *WizardService) enableUserServices() error {
529- if err := s.ensureWaybarService(); err != nil {
530- return err
531- }
532-
533- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "daemon-reload"); err != nil {
534- return fmt.Errorf("reload user systemd: %w", err)
535- }
536-
537- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "waybar.service"); err != nil {
538- return fmt.Errorf("enable waybar service: %w", err)
539- }
540- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "hypridle.service"); err != nil {
541- return fmt.Errorf("enable hypridle service: %w", err)
542- }
543-
544- return nil
545-}
546-
547-func (s *WizardService) ensureWaybarService() error {
548- home, err := os.UserHomeDir()
549- if err != nil {
550- return fmt.Errorf("resolve home directory: %w", err)
551- }
552-
553- unitDir := filepath.Join(home, ".config", "systemd", "user")
554- if err := s.fs.MkdirAll(unitDir, 0o755); err != nil {
555- return fmt.Errorf("create user systemd dir: %w", err)
556- }
557-
558- unitPath := filepath.Join(unitDir, "waybar.service")
559- unit := strings.Join([]string{
560- "[Unit]",
561- "Description=Waybar",
562- "PartOf=graphical-session.target",
563- "After=graphical-session.target",
564- "",
565- "[Service]",
566- "ExecStart=/usr/bin/waybar",
567- "Restart=on-failure",
568- "",
569- "[Install]",
570- "WantedBy=graphical-session.target",
571- "",
572- }, "\n")
573-
574- if err := s.fs.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
575- return fmt.Errorf("write waybar service: %w", err)
576- }
577-
578- return nil
579-}
580-
581-func (s *WizardService) applyHyprlandConfig(home string, config domain.DesktopConfig) error {
582- configDir := filepath.Join(home, ".config", "hypr")
583- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
584- return fmt.Errorf("create hypr config dir: %w", err)
585- }
586-
587- configPath := filepath.Join(configDir, "hyprland.conf")
588- includeLine := "source = ~/.config/hypr/archup.conf"
589- contents, err := s.readFileIfExists(configPath)
590- if err != nil {
591- return err
592- }
593- if !containsLine(contents, includeLine) {
594- contents = appendLine(contents, includeLine)
595- }
596- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
597- return fmt.Errorf("write hyprland config: %w", err)
598- }
599-
600- archupPath := filepath.Join(configDir, "archup.conf")
601- managed := hyprlandManagedConfig(config)
602- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
603- return fmt.Errorf("write hyprland managed config: %w", err)
604- }
605-
606- return nil
607-}
608-
609-func (s *WizardService) applyNiriConfig(home string, config domain.DesktopConfig) error {
610- configDir := filepath.Join(home, ".config", "niri")
611- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
612- return fmt.Errorf("create niri config dir: %w", err)
613- }
614-
615- configPath := filepath.Join(configDir, "config.kdl")
616- includeLine := "include \"archup.kdl\""
617- contents, err := s.readFileIfExists(configPath)
618- if err != nil {
619- return err
620- }
621- if !containsLine(contents, includeLine) {
622- contents = appendLine(contents, includeLine)
623- }
624- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
625- return fmt.Errorf("write niri config: %w", err)
626- }
627-
628- archupPath := filepath.Join(configDir, "archup.kdl")
629- managed := niriManagedConfig(config)
630- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
631- return fmt.Errorf("write niri managed config: %w", err)
632- }
633-
634- return nil
635-}
636-
637-func (s *WizardService) readFileIfExists(path string) (string, error) {
638- exists, err := s.fs.Exists(path)
639- if err != nil {
640- return "", fmt.Errorf("check config exists: %w", err)
641- }
642- if !exists {
643- return "", nil
644- }
645- data, err := s.fs.ReadFile(path)
646- if err != nil {
647- return "", fmt.Errorf("read config: %w", err)
648- }
649- return string(data), nil
650-}
651-
652-func containsLine(contents string, line string) bool {
653- for _, existing := range strings.Split(contents, "\n") {
654- if strings.TrimSpace(existing) == strings.TrimSpace(line) {
655- return true
656- }
657- }
658- return false
659-}
660-
661-func appendLine(contents string, line string) string {
662- trimmed := strings.TrimRight(contents, "\n")
663- if trimmed == "" {
664- return line + "\n"
665- }
666- return trimmed + "\n" + line + "\n"
667-}
668-
669-func hyprlandManagedConfig(config domain.DesktopConfig) string {
670- lines := []string{
671- "# ArchUp managed config",
672- "exec-once = swww-daemon",
673- "exec-once = swww img ~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png",
674- "exec-once = waybar",
675- "exec-once = mako",
676- "exec-once = hypridle",
677- "exec-once = /usr/lib/polkit-kde-authentication-agent-1",
678- "",
679- "bind = SUPER, T, exec, foot",
680- "bind = SUPER, D, exec, fuzzel",
681- "bind = SUPER, Q, killactive",
682- "bind = SUPER, L, exec, hyprlock",
683- "",
684- }
685-
686- if config.InstallCliphist {
687- lines = append(lines, "exec-once = wl-paste --type text --watch cliphist store")
688- lines = append(lines, "exec-once = wl-paste --type image --watch cliphist store")
689- lines = append(lines, "")
690- }
691-
692- return strings.Join(lines, "\n")
693-}
694-
695-func niriManagedConfig(config domain.DesktopConfig) string {
696- lines := []string{
697- "// ArchUp managed config",
698- "environment {",
699- " DISPLAY \":1\"",
700- " ELECTRON_OZONE_PLATFORM_HINT \"auto\"",
701- " QT_QPA_PLATFORM \"wayland\"",
702- " QT_WAYLAND_DISABLE_WINDOWDECORATION \"1\"",
703- " XDG_SESSION_TYPE \"wayland\"",
704- " XDG_CURRENT_DESKTOP \"niri\"",
705- "}",
706- "spawn-at-startup \"swww-daemon\"",
707- "spawn-at-startup \"swww\" \"img\" \"~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png\"",
708- "spawn-at-startup \"waybar\"",
709- "spawn-at-startup \"mako\"",
710- "spawn-at-startup \"hypridle\"",
711- "spawn-at-startup \"/usr/lib/polkit-kde-authentication-agent-1\"",
712- "",
713- "binds {",
714- " Mod+T { spawn \"foot\"; }",
715- " Mod+D { spawn \"fuzzel\"; }",
716- " Mod+Q { close-window; }",
717- " Mod+L { spawn \"hyprlock\"; }",
718- "}",
719- "",
720- }
721-
722- if config.InstallCliphist {
723- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"text\" \"--watch\" \"cliphist\" \"store\"")
724- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"image\" \"--watch\" \"cliphist\" \"store\"")
725- lines = append(lines, "")
726- }
727-
728- return strings.Join(lines, "\n")
729-}
730-
731-func uniquePackages(packages []string) []string {
732- seen := make(map[string]struct{}, len(packages))
733- result := make([]string, 0, len(packages))
734- for _, pkg := range packages {
735- if pkg == "" {
736- continue
737- }
738- if _, ok := seen[pkg]; ok {
739- continue
740- }
741- seen[pkg] = struct{}{}
742- result = append(result, pkg)
743- }
744- return result
745-}
746-
747-// MonitorOutput represents a detected monitor output.
748-type MonitorOutput struct {
749- Name string `json:"name"`
750- Enabled bool `json:"enabled"`
751- CurrentMode *MonitorMode `json:"current_mode"`
752- Modes []MonitorMode `json:"modes"`
753-}
754-
755-// MonitorMode represents a display mode.
756-type MonitorMode struct {
757- Width int `json:"width"`
758- Height int `json:"height"`
759- Refresh float64 `json:"refresh"`
760-}
761-
762-// MonitorConfig represents a desired monitor configuration.
763-type MonitorConfig struct {
764- Name string
765- Enabled bool
766- Width int
767- Height int
768- Refresh float64
769- PosX int
770- PosY int
771- Scale float64
772-}
773-
774-// DetectMonitors returns monitor outputs from wlr-randr.
775-func (s *WizardService) DetectMonitors(ctx context.Context) ([]MonitorOutput, error) {
776- output, err := s.cmdExec.Execute(ctx, "wlr-randr", "--json")
777- if err != nil {
778- return nil, fmt.Errorf("detect monitors: %w", err)
779- }
780-
781- var monitors []MonitorOutput
782- if err := json.Unmarshal(output, &monitors); err != nil {
783- return nil, fmt.Errorf("parse monitor JSON: %w", err)
784- }
785- return monitors, nil
786-}
787-
788-// ApplyMonitorConfig applies monitor settings using wlr-randr.
789-func (s *WizardService) ApplyMonitorConfig(ctx context.Context, configs []MonitorConfig) error {
790- for _, cfg := range configs {
791- args := []string{"--output", cfg.Name}
792- if cfg.Enabled {
793- args = append(args, "--on")
794- if cfg.Width > 0 && cfg.Height > 0 {
795- mode := fmt.Sprintf("%dx%d", cfg.Width, cfg.Height)
796- if cfg.Refresh > 0 {
797- mode = fmt.Sprintf("%s@%.2fHz", mode, cfg.Refresh)
798- }
799- args = append(args, "--mode", mode)
800- }
801- args = append(args, "--pos", fmt.Sprintf("%d,%d", cfg.PosX, cfg.PosY))
802- if cfg.Scale > 0 {
803- args = append(args, "--scale", fmt.Sprintf("%.2f", cfg.Scale))
804- }
805- } else {
806- args = append(args, "--off")
807- }
808-
809- if _, err := s.cmdExec.Execute(ctx, "wlr-randr", args...); err != nil {
810- return fmt.Errorf("apply monitor %s: %w", cfg.Name, err)
811- }
812- }
813- return nil
814-}
@@ -1,814 +0,0 @@
1-package services
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "os"
8- osuser "os/user"
9- "path/filepath"
10- "strings"
11-
12- "github.com/bnema/archup/internal/defaults"
13- "github.com/bnema/archup/internal/domain/ports"
14- "github.com/bnema/archup/internal/wizard/domain"
15-)
16-
17-// WizardService orchestrates the desktop setup wizard flow.
18-type WizardService struct {
19- fs ports.FileSystem
20- cmdExec ports.CommandExecutor
21- logger ports.Logger
22-}
23-
24-// NewWizardService creates a new wizard service.
25-func NewWizardService(fs ports.FileSystem, cmdExec ports.CommandExecutor, logger ports.Logger) *WizardService {
26- return &WizardService{
27- fs: fs,
28- cmdExec: cmdExec,
29- logger: logger,
30- }
31-}
32-
33-// Start begins the wizard run.
34-func (s *WizardService) Start(ctx context.Context, config domain.DesktopConfig) error {
35- return s.Install(ctx, config)
36-}
37-
38-// Install performs package installation and theme setup.
39-func (s *WizardService) Install(ctx context.Context, config domain.DesktopConfig) error {
40- s.logger.Info("Wizard install", "compositor", config.Compositor)
41- packages, err := s.buildPackageList(config)
42- if err != nil {
43- return err
44- }
45- if err := s.installPackages(ctx, packages); err != nil {
46- return err
47- }
48- if err := s.ensureBleuTheme(ctx); err != nil {
49- return err
50- }
51- return nil
52-}
53-
54-// ApplyConfig writes compositor configuration.
55-func (s *WizardService) ApplyConfig(config domain.DesktopConfig) error {
56- return s.ApplyConfigWithMonitors(config, nil)
57-}
58-
59-// ApplyConfigWithMonitors writes compositor configuration and monitor layout.
60-func (s *WizardService) ApplyConfigWithMonitors(config domain.DesktopConfig, monitors []MonitorConfig) error {
61- if err := s.applyCompositorConfig(config); err != nil {
62- return err
63- }
64- if err := s.ensureHyprlockConfig(); err != nil {
65- return err
66- }
67- if err := s.ensureHypridleConfig(); err != nil {
68- return err
69- }
70- if len(monitors) > 0 {
71- if err := s.writeMonitorConfig(config, monitors); err != nil {
72- return err
73- }
74- }
75- if config.EnableSDDM {
76- if err := s.ensureSDDMTheme(); err != nil {
77- return err
78- }
79- if err := s.ensureSDDMConfig(config); err != nil {
80- return err
81- }
82- if err := s.enableSDDMService(); err != nil {
83- return err
84- }
85- }
86- if err := s.ensureWaybarConfig(); err != nil {
87- return err
88- }
89- if err := s.applyGtkTheme(); err != nil {
90- return err
91- }
92- if err := s.enableUserServices(); err != nil {
93- return err
94- }
95- return nil
96-}
97-
98-func (s *WizardService) buildPackageList(config domain.DesktopConfig) ([]string, error) {
99- packages := []string{}
100-
101- core, err := defaults.ReadPackageList("desktop-core")
102- if err != nil {
103- return nil, err
104- }
105- packages = append(packages, core...)
106-
107- video, err := defaults.ReadPackageList("video-accel")
108- if err != nil {
109- return nil, err
110- }
111- packages = append(packages, video...)
112-
113- gtk, err := defaults.ReadPackageList("gtk")
114- if err != nil {
115- return nil, err
116- }
117- packages = append(packages, gtk...)
118-
119- if config.Compositor == domain.CompositorNiri {
120- compositor, err := defaults.ReadPackageList("compositor-niri")
121- if err != nil {
122- return nil, err
123- }
124- packages = append(packages, compositor...)
125- }
126-
127- if config.Compositor == domain.CompositorHyprland {
128- compositor, err := defaults.ReadPackageList("compositor-hyprland")
129- if err != nil {
130- return nil, err
131- }
132- packages = append(packages, compositor...)
133- }
134-
135- if config.EnableSDDM {
136- sddm, err := defaults.ReadPackageList("sddm")
137- if err != nil {
138- return nil, err
139- }
140- packages = append(packages, sddm...)
141- }
142-
143- if config.InstallCliphist {
144- packages = append(packages, "cliphist")
145- }
146-
147- return uniquePackages(packages), nil
148-}
149-
150-func (s *WizardService) installPackages(ctx context.Context, packages []string) error {
151- if len(packages) == 0 {
152- s.logger.Info("No packages to install")
153- return nil
154- }
155-
156- args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
157- s.logger.Info("Installing desktop packages", "count", len(packages))
158- if _, err := s.cmdExec.Execute(ctx, "pacman", args...); err != nil {
159- return fmt.Errorf("install packages: %w", err)
160- }
161-
162- return nil
163-}
164-
165-func (s *WizardService) applyCompositorConfig(config domain.DesktopConfig) error {
166- home, err := os.UserHomeDir()
167- if err != nil {
168- return fmt.Errorf("resolve home directory: %w", err)
169- }
170-
171- switch config.Compositor {
172- case domain.CompositorHyprland:
173- return s.applyHyprlandConfig(home, config)
174- case domain.CompositorNiri:
175- return s.applyNiriConfig(home, config)
176- default:
177- return nil
178- }
179-}
180-
181-func (s *WizardService) ensureBleuTheme(ctx context.Context) error {
182- home, err := os.UserHomeDir()
183- if err != nil {
184- return fmt.Errorf("resolve home directory: %w", err)
185- }
186-
187- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
188- exists, err := s.fs.Exists(themeDir)
189- if err != nil {
190- return fmt.Errorf("check bleu theme directory: %w", err)
191- }
192- if !exists {
193- if err := s.fs.MkdirAll(filepath.Dir(themeDir), 0o755); err != nil {
194- return fmt.Errorf("create theme parent directory: %w", err)
195- }
196- if _, err := s.cmdExec.Execute(ctx, "git", "clone", "https://github.com/bnema/bleu-theme", themeDir); err != nil {
197- return fmt.Errorf("clone bleu-theme: %w", err)
198- }
199- }
200-
201- wallpaperSource := filepath.Join(themeDir, "wallpapers", "wp-bleu-1.png")
202- wallpaperTarget := filepath.Join(home, "Pictures", "Wallpapers", "wp-bleu-1.png")
203- if err := s.fs.MkdirAll(filepath.Dir(wallpaperTarget), 0o755); err != nil {
204- return fmt.Errorf("create wallpaper directory: %w", err)
205- }
206- data, err := s.fs.ReadFile(wallpaperSource)
207- if err != nil {
208- return fmt.Errorf("read wallpaper: %w", err)
209- }
210- if err := s.fs.WriteFile(wallpaperTarget, data, 0o644); err != nil {
211- return fmt.Errorf("write wallpaper: %w", err)
212- }
213-
214- return nil
215-}
216-
217-func (s *WizardService) writeMonitorConfig(config domain.DesktopConfig, monitors []MonitorConfig) error {
218- home, err := os.UserHomeDir()
219- if err != nil {
220- return fmt.Errorf("resolve home directory: %w", err)
221- }
222-
223- switch config.Compositor {
224- case domain.CompositorHyprland:
225- return s.writeHyprlandMonitorConfig(home, monitors)
226- case domain.CompositorNiri:
227- return s.writeNiriMonitorConfig(home, monitors)
228- default:
229- return nil
230- }
231-}
232-
233-func (s *WizardService) writeHyprlandMonitorConfig(home string, monitors []MonitorConfig) error {
234- configDir := filepath.Join(home, ".config", "hypr")
235- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
236- return fmt.Errorf("create hypr config dir: %w", err)
237- }
238-
239- lines := []string{"# ArchUp monitors"}
240- for _, monitor := range monitors {
241- if !monitor.Enabled {
242- lines = append(lines, fmt.Sprintf("monitor = %s,disable", monitor.Name))
243- continue
244- }
245- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
246- pos := fmt.Sprintf("%dx%d", monitor.PosX, monitor.PosY)
247- scale := fmt.Sprintf("%.2f", monitor.Scale)
248- lines = append(lines, fmt.Sprintf("monitor = %s,%s,%s,%s", monitor.Name, mode, pos, scale))
249- }
250- lines = append(lines, "")
251-
252- path := filepath.Join(configDir, "archup-monitors.conf")
253- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
254- return fmt.Errorf("write hyprland monitor config: %w", err)
255- }
256-
257- includeLine := "source = ~/.config/hypr/archup-monitors.conf"
258- mainPath := filepath.Join(configDir, "hyprland.conf")
259- contents, err := s.readFileIfExists(mainPath)
260- if err != nil {
261- return err
262- }
263- if !containsLine(contents, includeLine) {
264- contents = appendLine(contents, includeLine)
265- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
266- return fmt.Errorf("write hyprland config: %w", err)
267- }
268- }
269-
270- return nil
271-}
272-
273-func (s *WizardService) writeNiriMonitorConfig(home string, monitors []MonitorConfig) error {
274- configDir := filepath.Join(home, ".config", "niri")
275- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
276- return fmt.Errorf("create niri config dir: %w", err)
277- }
278-
279- lines := []string{"// ArchUp monitors"}
280- for _, monitor := range monitors {
281- if !monitor.Enabled {
282- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
283- lines = append(lines, " off")
284- lines = append(lines, "}")
285- lines = append(lines, "")
286- continue
287- }
288- mode := fmt.Sprintf("%dx%d@%.2f", monitor.Width, monitor.Height, monitor.Refresh)
289- lines = append(lines, fmt.Sprintf("output \"%s\" {", monitor.Name))
290- lines = append(lines, fmt.Sprintf(" mode \"%s\"", mode))
291- lines = append(lines, fmt.Sprintf(" scale %.2f", monitor.Scale))
292- lines = append(lines, fmt.Sprintf(" position x=%d y=%d", monitor.PosX, monitor.PosY))
293- lines = append(lines, "}")
294- lines = append(lines, "")
295- }
296-
297- path := filepath.Join(configDir, "archup-monitors.kdl")
298- if err := s.writeIfChanged(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
299- return fmt.Errorf("write niri monitor config: %w", err)
300- }
301-
302- includeLine := "include \"archup-monitors.kdl\""
303- mainPath := filepath.Join(configDir, "config.kdl")
304- contents, err := s.readFileIfExists(mainPath)
305- if err != nil {
306- return err
307- }
308- if !containsLine(contents, includeLine) {
309- contents = appendLine(contents, includeLine)
310- if err := s.fs.WriteFile(mainPath, []byte(contents), 0o644); err != nil {
311- return fmt.Errorf("write niri config: %w", err)
312- }
313- }
314-
315- return nil
316-}
317-
318-func (s *WizardService) ensureHyprlockConfig() error {
319- home, err := os.UserHomeDir()
320- if err != nil {
321- return fmt.Errorf("resolve home directory: %w", err)
322- }
323-
324- themeDir := filepath.Join(home, ".config", "themes", "bleu-theme")
325- configDir := filepath.Join(home, ".config", "hypr")
326- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
327- return fmt.Errorf("create hypr config dir: %w", err)
328- }
329-
330- source := filepath.Join(themeDir, "hyprlock", "hyprlock.conf")
331- target := filepath.Join(configDir, "hyprlock.conf")
332- data, err := s.fs.ReadFile(source)
333- if err != nil {
334- return fmt.Errorf("read hyprlock config: %w", err)
335- }
336- if err := s.writeIfChanged(target, data, 0o644); err != nil {
337- return fmt.Errorf("write hyprlock config: %w", err)
338- }
339-
340- return nil
341-}
342-
343-func (s *WizardService) ensureHypridleConfig() error {
344- home, err := os.UserHomeDir()
345- if err != nil {
346- return fmt.Errorf("resolve home directory: %w", err)
347- }
348-
349- configDir := filepath.Join(home, ".config", "hypr")
350- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
351- return fmt.Errorf("create hypr config dir: %w", err)
352- }
353-
354- configPath := filepath.Join(configDir, "hypridle.conf")
355- content := strings.Join([]string{
356- "general {",
357- " lock_cmd = hyprlock",
358- " before_sleep_cmd = loginctl lock-session",
359- " after_sleep_cmd = notify-send \"Welcome back\"",
360- "}",
361- "",
362- "listener {",
363- " timeout = 600",
364- " on-timeout = hyprlock",
365- " on-resume = notify-send \"Unlocked\"",
366- "}",
367- "",
368- }, "\n")
369-
370- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
371- return fmt.Errorf("write hypridle config: %w", err)
372- }
373-
374- return nil
375-}
376-
377-func (s *WizardService) ensureSDDMTheme() error {
378- home, err := os.UserHomeDir()
379- if err != nil {
380- return fmt.Errorf("resolve home directory: %w", err)
381- }
382-
383- source := filepath.Join(home, ".config", "themes", "bleu-theme", "sddm", "bleu-terminal")
384- if exists, err := s.fs.Exists(source); err != nil {
385- return fmt.Errorf("check sddm theme source: %w", err)
386- } else if !exists {
387- return fmt.Errorf("sddm theme source not found: %s", source)
388- }
389-
390- target := "/usr/share/sddm/themes/bleu-terminal"
391- if _, err := s.cmdExec.Execute(context.Background(), "cp", "-r", source, target); err != nil {
392- return fmt.Errorf("copy sddm theme: %w", err)
393- }
394-
395- return nil
396-}
397-
398-func (s *WizardService) ensureSDDMConfig(config domain.DesktopConfig) error {
399- configDir := "/etc/sddm.conf.d"
400- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
401- return fmt.Errorf("create sddm config dir: %w", err)
402- }
403-
404- lines := []string{
405- "[Theme]",
406- "Current=bleu-terminal",
407- }
408-
409- if config.AutoLogin {
410- userName := resolveLoginUser()
411- if userName != "" {
412- lines = append(lines, "")
413- lines = append(lines, "[Autologin]")
414- lines = append(lines, fmt.Sprintf("User=%s", userName))
415- lines = append(lines, fmt.Sprintf("Session=%s", sddmSessionName(config.Compositor)))
416- }
417- }
418-
419- content := strings.Join(lines, "\n") + "\n"
420- configPath := filepath.Join(configDir, "archup.conf")
421- if err := s.writeIfChanged(configPath, []byte(content), 0o644); err != nil {
422- return fmt.Errorf("write sddm config: %w", err)
423- }
424-
425- return nil
426-}
427-
428-func (s *WizardService) enableSDDMService() error {
429- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "enable", "--now", "sddm"); err != nil {
430- return fmt.Errorf("enable sddm service: %w", err)
431- }
432- return nil
433-}
434-
435-func resolveLoginUser() string {
436- if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
437- return sudoUser
438- }
439- current, err := osuser.Current()
440- if err != nil {
441- return ""
442- }
443- return current.Username
444-}
445-
446-func sddmSessionName(compositor domain.Compositor) string {
447- if compositor == domain.CompositorHyprland {
448- return "hyprland"
449- }
450- return "niri"
451-}
452-
453-func (s *WizardService) ensureWaybarConfig() error {
454- home, err := os.UserHomeDir()
455- if err != nil {
456- return fmt.Errorf("resolve home directory: %w", err)
457- }
458-
459- configDir := filepath.Join(home, ".config", "waybar")
460- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
461- return fmt.Errorf("create waybar config dir: %w", err)
462- }
463-
464- configPath := filepath.Join(configDir, "config")
465- templatePath := filepath.Join("install", "configs", "waybar", "config.json")
466- configData, err := s.fs.ReadFile(templatePath)
467- if err != nil {
468- return fmt.Errorf("read waybar config template: %w", err)
469- }
470- if err := s.writeIfChanged(configPath, configData, 0o644); err != nil {
471- return fmt.Errorf("write waybar config: %w", err)
472- }
473-
474- stylePath := filepath.Join(configDir, "style.css")
475- source := filepath.Join(home, ".config", "themes", "bleu-theme", "waybar", "bleu.css")
476- data, err := s.fs.ReadFile(source)
477- if err != nil {
478- return fmt.Errorf("read waybar style: %w", err)
479- }
480- if err := s.writeIfChanged(stylePath, data, 0o644); err != nil {
481- return fmt.Errorf("write waybar style: %w", err)
482- }
483-
484- scriptDir := filepath.Join(configDir, "scripts")
485- if err := s.fs.MkdirAll(scriptDir, 0o755); err != nil {
486- return fmt.Errorf("create waybar scripts dir: %w", err)
487- }
488- scriptPath := filepath.Join(scriptDir, "theme-toggle.sh")
489- scriptSource := filepath.Join("install", "configs", "waybar", "theme-toggle.sh")
490- scriptData, err := s.fs.ReadFile(scriptSource)
491- if err != nil {
492- return fmt.Errorf("read waybar theme toggle script: %w", err)
493- }
494- if err := s.writeIfChanged(scriptPath, scriptData, 0o755); err != nil {
495- return fmt.Errorf("write waybar theme toggle script: %w", err)
496- }
497-
498- return nil
499-}
500-
501-func (s *WizardService) writeIfChanged(path string, data []byte, perm os.FileMode) error {
502- exists, err := s.fs.Exists(path)
503- if err != nil {
504- return err
505- }
506- if exists {
507- existing, err := s.fs.ReadFile(path)
508- if err != nil {
509- return err
510- }
511- if string(existing) == string(data) {
512- return nil
513- }
514- }
515- return s.fs.WriteFile(path, data, perm)
516-}
517-
518-func (s *WizardService) applyGtkTheme() error {
519- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"); err != nil {
520- return fmt.Errorf("set GTK color scheme: %w", err)
521- }
522- if _, err := s.cmdExec.Execute(context.Background(), "gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "adw-gtk3-dark"); err != nil {
523- return fmt.Errorf("set GTK theme: %w", err)
524- }
525- return nil
526-}
527-
528-func (s *WizardService) enableUserServices() error {
529- if err := s.ensureWaybarService(); err != nil {
530- return err
531- }
532-
533- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "daemon-reload"); err != nil {
534- return fmt.Errorf("reload user systemd: %w", err)
535- }
536-
537- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "waybar.service"); err != nil {
538- return fmt.Errorf("enable waybar service: %w", err)
539- }
540- if _, err := s.cmdExec.Execute(context.Background(), "systemctl", "--user", "enable", "--now", "hypridle.service"); err != nil {
541- return fmt.Errorf("enable hypridle service: %w", err)
542- }
543-
544- return nil
545-}
546-
547-func (s *WizardService) ensureWaybarService() error {
548- home, err := os.UserHomeDir()
549- if err != nil {
550- return fmt.Errorf("resolve home directory: %w", err)
551- }
552-
553- unitDir := filepath.Join(home, ".config", "systemd", "user")
554- if err := s.fs.MkdirAll(unitDir, 0o755); err != nil {
555- return fmt.Errorf("create user systemd dir: %w", err)
556- }
557-
558- unitPath := filepath.Join(unitDir, "waybar.service")
559- unit := strings.Join([]string{
560- "[Unit]",
561- "Description=Waybar",
562- "PartOf=graphical-session.target",
563- "After=graphical-session.target",
564- "",
565- "[Service]",
566- "ExecStart=/usr/bin/waybar",
567- "Restart=on-failure",
568- "",
569- "[Install]",
570- "WantedBy=graphical-session.target",
571- "",
572- }, "\n")
573-
574- if err := s.fs.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
575- return fmt.Errorf("write waybar service: %w", err)
576- }
577-
578- return nil
579-}
580-
581-func (s *WizardService) applyHyprlandConfig(home string, config domain.DesktopConfig) error {
582- configDir := filepath.Join(home, ".config", "hypr")
583- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
584- return fmt.Errorf("create hypr config dir: %w", err)
585- }
586-
587- configPath := filepath.Join(configDir, "hyprland.conf")
588- includeLine := "source = ~/.config/hypr/archup.conf"
589- contents, err := s.readFileIfExists(configPath)
590- if err != nil {
591- return err
592- }
593- if !containsLine(contents, includeLine) {
594- contents = appendLine(contents, includeLine)
595- }
596- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
597- return fmt.Errorf("write hyprland config: %w", err)
598- }
599-
600- archupPath := filepath.Join(configDir, "archup.conf")
601- managed := hyprlandManagedConfig(config)
602- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
603- return fmt.Errorf("write hyprland managed config: %w", err)
604- }
605-
606- return nil
607-}
608-
609-func (s *WizardService) applyNiriConfig(home string, config domain.DesktopConfig) error {
610- configDir := filepath.Join(home, ".config", "niri")
611- if err := s.fs.MkdirAll(configDir, 0o755); err != nil {
612- return fmt.Errorf("create niri config dir: %w", err)
613- }
614-
615- configPath := filepath.Join(configDir, "config.kdl")
616- includeLine := "include \"archup.kdl\""
617- contents, err := s.readFileIfExists(configPath)
618- if err != nil {
619- return err
620- }
621- if !containsLine(contents, includeLine) {
622- contents = appendLine(contents, includeLine)
623- }
624- if err := s.fs.WriteFile(configPath, []byte(contents), 0o644); err != nil {
625- return fmt.Errorf("write niri config: %w", err)
626- }
627-
628- archupPath := filepath.Join(configDir, "archup.kdl")
629- managed := niriManagedConfig(config)
630- if err := s.fs.WriteFile(archupPath, []byte(managed), 0o644); err != nil {
631- return fmt.Errorf("write niri managed config: %w", err)
632- }
633-
634- return nil
635-}
636-
637-func (s *WizardService) readFileIfExists(path string) (string, error) {
638- exists, err := s.fs.Exists(path)
639- if err != nil {
640- return "", fmt.Errorf("check config exists: %w", err)
641- }
642- if !exists {
643- return "", nil
644- }
645- data, err := s.fs.ReadFile(path)
646- if err != nil {
647- return "", fmt.Errorf("read config: %w", err)
648- }
649- return string(data), nil
650-}
651-
652-func containsLine(contents string, line string) bool {
653- for _, existing := range strings.Split(contents, "\n") {
654- if strings.TrimSpace(existing) == strings.TrimSpace(line) {
655- return true
656- }
657- }
658- return false
659-}
660-
661-func appendLine(contents string, line string) string {
662- trimmed := strings.TrimRight(contents, "\n")
663- if trimmed == "" {
664- return line + "\n"
665- }
666- return trimmed + "\n" + line + "\n"
667-}
668-
669-func hyprlandManagedConfig(config domain.DesktopConfig) string {
670- lines := []string{
671- "# ArchUp managed config",
672- "exec-once = swww-daemon",
673- "exec-once = swww img ~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png",
674- "exec-once = waybar",
675- "exec-once = mako",
676- "exec-once = hypridle",
677- "exec-once = /usr/lib/polkit-kde-authentication-agent-1",
678- "",
679- "bind = SUPER, T, exec, foot",
680- "bind = SUPER, D, exec, fuzzel",
681- "bind = SUPER, Q, killactive",
682- "bind = SUPER, L, exec, hyprlock",
683- "",
684- }
685-
686- if config.InstallCliphist {
687- lines = append(lines, "exec-once = wl-paste --type text --watch cliphist store")
688- lines = append(lines, "exec-once = wl-paste --type image --watch cliphist store")
689- lines = append(lines, "")
690- }
691-
692- return strings.Join(lines, "\n")
693-}
694-
695-func niriManagedConfig(config domain.DesktopConfig) string {
696- lines := []string{
697- "// ArchUp managed config",
698- "environment {",
699- " DISPLAY \":1\"",
700- " ELECTRON_OZONE_PLATFORM_HINT \"auto\"",
701- " QT_QPA_PLATFORM \"wayland\"",
702- " QT_WAYLAND_DISABLE_WINDOWDECORATION \"1\"",
703- " XDG_SESSION_TYPE \"wayland\"",
704- " XDG_CURRENT_DESKTOP \"niri\"",
705- "}",
706- "spawn-at-startup \"swww-daemon\"",
707- "spawn-at-startup \"swww\" \"img\" \"~/.config/themes/bleu-theme/wallpapers/wp-bleu-1.png\"",
708- "spawn-at-startup \"waybar\"",
709- "spawn-at-startup \"mako\"",
710- "spawn-at-startup \"hypridle\"",
711- "spawn-at-startup \"/usr/lib/polkit-kde-authentication-agent-1\"",
712- "",
713- "binds {",
714- " Mod+T { spawn \"foot\"; }",
715- " Mod+D { spawn \"fuzzel\"; }",
716- " Mod+Q { close-window; }",
717- " Mod+L { spawn \"hyprlock\"; }",
718- "}",
719- "",
720- }
721-
722- if config.InstallCliphist {
723- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"text\" \"--watch\" \"cliphist\" \"store\"")
724- lines = append(lines, "spawn-at-startup \"wl-paste\" \"--type\" \"image\" \"--watch\" \"cliphist\" \"store\"")
725- lines = append(lines, "")
726- }
727-
728- return strings.Join(lines, "\n")
729-}
730-
731-func uniquePackages(packages []string) []string {
732- seen := make(map[string]struct{}, len(packages))
733- result := make([]string, 0, len(packages))
734- for _, pkg := range packages {
735- if pkg == "" {
736- continue
737- }
738- if _, ok := seen[pkg]; ok {
739- continue
740- }
741- seen[pkg] = struct{}{}
742- result = append(result, pkg)
743- }
744- return result
745-}
746-
747-// MonitorOutput represents a detected monitor output.
748-type MonitorOutput struct {
749- Name string `json:"name"`
750- Enabled bool `json:"enabled"`
751- CurrentMode *MonitorMode `json:"current_mode"`
752- Modes []MonitorMode `json:"modes"`
753-}
754-
755-// MonitorMode represents a display mode.
756-type MonitorMode struct {
757- Width int `json:"width"`
758- Height int `json:"height"`
759- Refresh float64 `json:"refresh"`
760-}
761-
762-// MonitorConfig represents a desired monitor configuration.
763-type MonitorConfig struct {
764- Name string
765- Enabled bool
766- Width int
767- Height int
768- Refresh float64
769- PosX int
770- PosY int
771- Scale float64
772-}
773-
774-// DetectMonitors returns monitor outputs from wlr-randr.
775-func (s *WizardService) DetectMonitors(ctx context.Context) ([]MonitorOutput, error) {
776- output, err := s.cmdExec.Execute(ctx, "wlr-randr", "--json")
777- if err != nil {
778- return nil, fmt.Errorf("detect monitors: %w", err)
779- }
780-
781- var monitors []MonitorOutput
782- if err := json.Unmarshal(output, &monitors); err != nil {
783- return nil, fmt.Errorf("parse monitor JSON: %w", err)
784- }
785- return monitors, nil
786-}
787-
788-// ApplyMonitorConfig applies monitor settings using wlr-randr.
789-func (s *WizardService) ApplyMonitorConfig(ctx context.Context, configs []MonitorConfig) error {
790- for _, cfg := range configs {
791- args := []string{"--output", cfg.Name}
792- if cfg.Enabled {
793- args = append(args, "--on")
794- if cfg.Width > 0 && cfg.Height > 0 {
795- mode := fmt.Sprintf("%dx%d", cfg.Width, cfg.Height)
796- if cfg.Refresh > 0 {
797- mode = fmt.Sprintf("%s@%.2fHz", mode, cfg.Refresh)
798- }
799- args = append(args, "--mode", mode)
800- }
801- args = append(args, "--pos", fmt.Sprintf("%d,%d", cfg.PosX, cfg.PosY))
802- if cfg.Scale > 0 {
803- args = append(args, "--scale", fmt.Sprintf("%.2f", cfg.Scale))
804- }
805- } else {
806- args = append(args, "--off")
807- }
808-
809- if _, err := s.cmdExec.Execute(ctx, "wlr-randr", args...); err != nil {
810- return fmt.Errorf("apply monitor %s: %w", cfg.Name, err)
811- }
812- }
813- return nil
814-}
D internal/wizard/domain/compositor.go +0 -9
@@ -1,9 +0,0 @@
1-package domain
2-
3-// Compositor represents supported Wayland compositors.
4-type Compositor string
5-
6-const (
7- CompositorNiri Compositor = "niri"
8- CompositorHyprland Compositor = "hyprland"
9-)
@@ -1,9 +0,0 @@
1-package domain
2-
3-// Compositor represents supported Wayland compositors.
4-type Compositor string
5-
6-const (
7- CompositorNiri Compositor = "niri"
8- CompositorHyprland Compositor = "hyprland"
9-)
D internal/wizard/domain/desktop.go +0 -21
@@ -1,21 +0,0 @@
1-package domain
2-
3-// DesktopConfig represents wizard selections for desktop setup.
4-type DesktopConfig struct {
5- Compositor Compositor
6- EnableSDDM bool
7- AutoLogin bool
8- AutoLoginUser string
9- InstallCliphist bool
10-}
11-
12-// NewDesktopConfig returns defaults for a new wizard run.
13-func NewDesktopConfig() DesktopConfig {
14- return DesktopConfig{
15- Compositor: CompositorNiri,
16- EnableSDDM: true,
17- AutoLogin: false,
18- AutoLoginUser: "",
19- InstallCliphist: false,
20- }
21-}
@@ -1,21 +0,0 @@
1-package domain
2-
3-// DesktopConfig represents wizard selections for desktop setup.
4-type DesktopConfig struct {
5- Compositor Compositor
6- EnableSDDM bool
7- AutoLogin bool
8- AutoLoginUser string
9- InstallCliphist bool
10-}
11-
12-// NewDesktopConfig returns defaults for a new wizard run.
13-func NewDesktopConfig() DesktopConfig {
14- return DesktopConfig{
15- Compositor: CompositorNiri,
16- EnableSDDM: true,
17- AutoLogin: false,
18- AutoLoginUser: "",
19- InstallCliphist: false,
20- }
21-}
D internal/wizard/domain/packages.go +0 -7
@@ -1,7 +0,0 @@
1-package domain
2-
3-// PackageSet defines a named list of packages.
4-type PackageSet struct {
5- Name string
6- Packages []string
7-}
@@ -1,7 +0,0 @@
1-package domain
2-
3-// PackageSet defines a named list of packages.
4-type PackageSet struct {
5- Name string
6- Packages []string
7-}
D internal/wizard/interfaces/tui/views/apply_config.go +0 -23
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderApplyConfig renders the config apply step.
10-func RenderApplyConfig() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Applying Configuration"))
18- b.WriteString("\n\n")
19- b.WriteString("Writing compositor config and enabling services...\n\n")
20- b.WriteString(muted.Render("Please wait."))
21-
22- return b.String()
23-}
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderApplyConfig renders the config apply step.
10-func RenderApplyConfig() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Applying Configuration"))
18- b.WriteString("\n\n")
19- b.WriteString("Writing compositor config and enabling services...\n\n")
20- b.WriteString(muted.Render("Please wait."))
21-
22- return b.String()
23-}
D internal/wizard/interfaces/tui/views/complete.go +0 -34
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderComplete renders completion screen.
11-func RenderComplete(config domain.DesktopConfig, errMsg string) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- alert := lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
17-
18- b.WriteString("\n")
19- if errMsg != "" {
20- b.WriteString(alert.Render("Wizard finished with errors"))
21- b.WriteString("\n\n")
22- b.WriteString(alert.Render(errMsg))
23- b.WriteString("\n\n")
24- } else {
25- b.WriteString(title.Render("Wizard Complete"))
26- b.WriteString("\n\n")
27- b.WriteString("Desktop setup started for: " + string(config.Compositor))
28- b.WriteString("\n\n")
29- }
30-
31- b.WriteString(muted.Render("Press Enter to exit."))
32-
33- return b.String()
34-}
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderComplete renders completion screen.
11-func RenderComplete(config domain.DesktopConfig, errMsg string) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- alert := lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
17-
18- b.WriteString("\n")
19- if errMsg != "" {
20- b.WriteString(alert.Render("Wizard finished with errors"))
21- b.WriteString("\n\n")
22- b.WriteString(alert.Render(errMsg))
23- b.WriteString("\n\n")
24- } else {
25- b.WriteString(title.Render("Wizard Complete"))
26- b.WriteString("\n\n")
27- b.WriteString("Desktop setup started for: " + string(config.Compositor))
28- b.WriteString("\n\n")
29- }
30-
31- b.WriteString(muted.Render("Press Enter to exit."))
32-
33- return b.String()
34-}
D internal/wizard/interfaces/tui/views/compositor.go +0 -39
@@ -1,39 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderCompositor renders compositor selection.
11-func RenderCompositor(config domain.DesktopConfig, selected int) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17-
18- options := []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
19-
20- b.WriteString("\n")
21- b.WriteString(title.Render("Select Compositor"))
22- b.WriteString("\n\n")
23-
24- for i, option := range options {
25- label := string(option)
26- if i == selected {
27- b.WriteString(active.Render("› " + label))
28- } else {
29- b.WriteString(" " + label)
30- }
31- b.WriteString("\n")
32- }
33-
34- b.WriteString("\n")
35- b.WriteString(muted.Render("↑↓ Move • Enter Select • Ctrl+C Quit"))
36-
37- _ = config
38- return b.String()
39-}
@@ -1,39 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderCompositor renders compositor selection.
11-func RenderCompositor(config domain.DesktopConfig, selected int) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16- active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
17-
18- options := []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
19-
20- b.WriteString("\n")
21- b.WriteString(title.Render("Select Compositor"))
22- b.WriteString("\n\n")
23-
24- for i, option := range options {
25- label := string(option)
26- if i == selected {
27- b.WriteString(active.Render("› " + label))
28- } else {
29- b.WriteString(" " + label)
30- }
31- b.WriteString("\n")
32- }
33-
34- b.WriteString("\n")
35- b.WriteString(muted.Render("↑↓ Move • Enter Select • Ctrl+C Quit"))
36-
37- _ = config
38- return b.String()
39-}
D internal/wizard/interfaces/tui/views/confirm.go +0 -42
@@ -1,42 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderConfirm renders confirmation screen.
11-func RenderConfirm(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "no"
18- if config.InstallCliphist {
19- cliphistStatus = "yes"
20- }
21-
22- sddmStatus := "no"
23- if config.EnableSDDM {
24- sddmStatus = "yes"
25- }
26-
27- autoLoginStatus := "no"
28- if config.AutoLogin {
29- autoLoginStatus = "yes"
30- }
31-
32- b.WriteString("\n")
33- b.WriteString(title.Render("Confirm Selections"))
34- b.WriteString("\n\n")
35- b.WriteString("Compositor: " + string(config.Compositor) + "\n")
36- b.WriteString("SDDM: " + sddmStatus + "\n")
37- b.WriteString("Auto-login: " + autoLoginStatus + "\n")
38- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
39- b.WriteString(muted.Render("Enter Install • B Back"))
40-
41- return b.String()
42-}
@@ -1,42 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderConfirm renders confirmation screen.
11-func RenderConfirm(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "no"
18- if config.InstallCliphist {
19- cliphistStatus = "yes"
20- }
21-
22- sddmStatus := "no"
23- if config.EnableSDDM {
24- sddmStatus = "yes"
25- }
26-
27- autoLoginStatus := "no"
28- if config.AutoLogin {
29- autoLoginStatus = "yes"
30- }
31-
32- b.WriteString("\n")
33- b.WriteString(title.Render("Confirm Selections"))
34- b.WriteString("\n\n")
35- b.WriteString("Compositor: " + string(config.Compositor) + "\n")
36- b.WriteString("SDDM: " + sddmStatus + "\n")
37- b.WriteString("Auto-login: " + autoLoginStatus + "\n")
38- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
39- b.WriteString(muted.Render("Enter Install • B Back"))
40-
41- return b.String()
42-}
D internal/wizard/interfaces/tui/views/install.go +0 -23
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderInstall renders a placeholder install screen.
10-func RenderInstall() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Installing"))
18- b.WriteString("\n\n")
19- b.WriteString("Wizard install is not implemented yet.\n\n")
20- b.WriteString(muted.Render("Running setup..."))
21-
22- return b.String()
23-}
@@ -1,23 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/charmbracelet/lipgloss"
7-)
8-
9-// RenderInstall renders a placeholder install screen.
10-func RenderInstall() string {
11- var b strings.Builder
12-
13- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
14- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
15-
16- b.WriteString("\n")
17- b.WriteString(title.Render("Installing"))
18- b.WriteString("\n\n")
19- b.WriteString("Wizard install is not implemented yet.\n\n")
20- b.WriteString(muted.Render("Running setup..."))
21-
22- return b.String()
23-}
D internal/wizard/interfaces/tui/views/monitors.go +0 -80
@@ -1,80 +0,0 @@
1-package views
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/archup/internal/wizard/application/services"
8- "github.com/charmbracelet/lipgloss"
9-)
10-
11-// RenderMonitors renders monitor configuration info.
12-func RenderMonitors(monitors []services.MonitorOutput, configs []services.MonitorConfig, selected int, applying bool, dirty bool, errMsg string) string {
13- var b strings.Builder
14-
15- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
16- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
17-
18- b.WriteString("\n")
19- b.WriteString(title.Render("Monitor Configuration"))
20- b.WriteString("\n\n")
21-
22- if len(monitors) == 0 {
23- b.WriteString("No monitors detected.\n\n")
24- b.WriteString(muted.Render("Press Enter to continue."))
25- return b.String()
26- }
27-
28- selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
29- for i, monitor := range monitors {
30- status := "off"
31- if monitor.Enabled {
32- status = "on"
33- }
34- label := fmt.Sprintf("%s (%s)", monitor.Name, status)
35- if i == selected {
36- label = selectedStyle.Render("› " + label)
37- } else {
38- label = " " + label
39- }
40- b.WriteString(label)
41- modeWidth, modeHeight, modeRefresh := 0, 0, 0.0
42- if i < len(configs) {
43- modeWidth = configs[i].Width
44- modeHeight = configs[i].Height
45- modeRefresh = configs[i].Refresh
46- } else if monitor.CurrentMode != nil {
47- modeWidth = monitor.CurrentMode.Width
48- modeHeight = monitor.CurrentMode.Height
49- modeRefresh = monitor.CurrentMode.Refresh
50- }
51- if modeWidth > 0 && modeHeight > 0 {
52- b.WriteString(fmt.Sprintf(" - %dx%d @ %.2fHz", modeWidth, modeHeight, modeRefresh))
53- }
54- b.WriteString("\n")
55-
56- if i < len(configs) {
57- cfg := configs[i]
58- b.WriteString(muted.Render(fmt.Sprintf(" pos=%d,%d scale=%.2f", cfg.PosX, cfg.PosY, cfg.Scale)))
59- b.WriteString("\n")
60- }
61- }
62-
63- if errMsg != "" {
64- b.WriteString("\n")
65- b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(errMsg))
66- }
67-
68- b.WriteString("\n")
69- if applying {
70- b.WriteString(muted.Render("Applying changes..."))
71- } else if dirty {
72- b.WriteString(muted.Render("Changes pending..."))
73- } else {
74- b.WriteString(muted.Render("Detected via wlr-randr --json"))
75- }
76- b.WriteString("\n")
77- b.WriteString(muted.Render("↑↓ Select • H/L X-pos • J/K Y-pos • +/- Scale • M Cycle Mode • Space Toggle • Enter Next"))
78-
79- return b.String()
80-}
@@ -1,80 +0,0 @@
1-package views
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/archup/internal/wizard/application/services"
8- "github.com/charmbracelet/lipgloss"
9-)
10-
11-// RenderMonitors renders monitor configuration info.
12-func RenderMonitors(monitors []services.MonitorOutput, configs []services.MonitorConfig, selected int, applying bool, dirty bool, errMsg string) string {
13- var b strings.Builder
14-
15- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
16- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
17-
18- b.WriteString("\n")
19- b.WriteString(title.Render("Monitor Configuration"))
20- b.WriteString("\n\n")
21-
22- if len(monitors) == 0 {
23- b.WriteString("No monitors detected.\n\n")
24- b.WriteString(muted.Render("Press Enter to continue."))
25- return b.String()
26- }
27-
28- selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
29- for i, monitor := range monitors {
30- status := "off"
31- if monitor.Enabled {
32- status = "on"
33- }
34- label := fmt.Sprintf("%s (%s)", monitor.Name, status)
35- if i == selected {
36- label = selectedStyle.Render("› " + label)
37- } else {
38- label = " " + label
39- }
40- b.WriteString(label)
41- modeWidth, modeHeight, modeRefresh := 0, 0, 0.0
42- if i < len(configs) {
43- modeWidth = configs[i].Width
44- modeHeight = configs[i].Height
45- modeRefresh = configs[i].Refresh
46- } else if monitor.CurrentMode != nil {
47- modeWidth = monitor.CurrentMode.Width
48- modeHeight = monitor.CurrentMode.Height
49- modeRefresh = monitor.CurrentMode.Refresh
50- }
51- if modeWidth > 0 && modeHeight > 0 {
52- b.WriteString(fmt.Sprintf(" - %dx%d @ %.2fHz", modeWidth, modeHeight, modeRefresh))
53- }
54- b.WriteString("\n")
55-
56- if i < len(configs) {
57- cfg := configs[i]
58- b.WriteString(muted.Render(fmt.Sprintf(" pos=%d,%d scale=%.2f", cfg.PosX, cfg.PosY, cfg.Scale)))
59- b.WriteString("\n")
60- }
61- }
62-
63- if errMsg != "" {
64- b.WriteString("\n")
65- b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(errMsg))
66- }
67-
68- b.WriteString("\n")
69- if applying {
70- b.WriteString(muted.Render("Applying changes..."))
71- } else if dirty {
72- b.WriteString(muted.Render("Changes pending..."))
73- } else {
74- b.WriteString(muted.Render("Detected via wlr-randr --json"))
75- }
76- b.WriteString("\n")
77- b.WriteString(muted.Render("↑↓ Select • H/L X-pos • J/K Y-pos • +/- Scale • M Cycle Mode • Space Toggle • Enter Next"))
78-
79- return b.String()
80-}
D internal/wizard/interfaces/tui/views/optional.go +0 -29
@@ -1,29 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderOptional renders optional component toggles.
11-func RenderOptional(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "off"
18- if config.InstallCliphist {
19- cliphistStatus = "on"
20- }
21-
22- b.WriteString("\n")
23- b.WriteString(title.Render("Optional Components"))
24- b.WriteString("\n\n")
25- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
26- b.WriteString(muted.Render("Space Toggle • Enter Next"))
27-
28- return b.String()
29-}
@@ -1,29 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderOptional renders optional component toggles.
11-func RenderOptional(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- cliphistStatus := "off"
18- if config.InstallCliphist {
19- cliphistStatus = "on"
20- }
21-
22- b.WriteString("\n")
23- b.WriteString(title.Render("Optional Components"))
24- b.WriteString("\n\n")
25- b.WriteString("Cliphist: " + cliphistStatus + "\n\n")
26- b.WriteString(muted.Render("Space Toggle • Enter Next"))
27-
28- return b.String()
29-}
D internal/wizard/interfaces/tui/views/sddm.go +0 -34
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderSDDM renders the SDDM selection screen.
11-func RenderSDDM(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- sddmStatus := "disabled"
18- if config.EnableSDDM {
19- sddmStatus = "enabled"
20- }
21- autoLoginStatus := "off"
22- if config.AutoLogin {
23- autoLoginStatus = "on"
24- }
25-
26- b.WriteString("\n")
27- b.WriteString(title.Render("Display Manager"))
28- b.WriteString("\n\n")
29- b.WriteString("SDDM: " + sddmStatus + "\n")
30- b.WriteString("Auto-login: " + autoLoginStatus + "\n\n")
31- b.WriteString(muted.Render("Space Toggle SDDM • A Toggle Auto-login • Enter Next"))
32-
33- return b.String()
34-}
@@ -1,34 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderSDDM renders the SDDM selection screen.
11-func RenderSDDM(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- sddmStatus := "disabled"
18- if config.EnableSDDM {
19- sddmStatus = "enabled"
20- }
21- autoLoginStatus := "off"
22- if config.AutoLogin {
23- autoLoginStatus = "on"
24- }
25-
26- b.WriteString("\n")
27- b.WriteString(title.Render("Display Manager"))
28- b.WriteString("\n\n")
29- b.WriteString("SDDM: " + sddmStatus + "\n")
30- b.WriteString("Auto-login: " + autoLoginStatus + "\n\n")
31- b.WriteString(muted.Render("Space Toggle SDDM • A Toggle Auto-login • Enter Next"))
32-
33- return b.String()
34-}
D internal/wizard/interfaces/tui/views/welcome.go +0 -25
@@ -1,25 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderWelcome renders the wizard welcome screen.
11-func RenderWelcome(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- b.WriteString("\n")
18- b.WriteString(title.Render("ArchUp Desktop Wizard"))
19- b.WriteString("\n\n")
20- b.WriteString("This wizard will install a Wayland desktop and apply defaults.\n")
21- b.WriteString("You can re-run it safely to reconfigure.\n\n")
22- b.WriteString(muted.Render("Default compositor: " + string(config.Compositor) + "\n"))
23- b.WriteString(muted.Render("Press Enter to begin or Ctrl+C to exit."))
24- return b.String()
25-}
@@ -1,25 +0,0 @@
1-package views
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/archup/internal/wizard/domain"
7- "github.com/charmbracelet/lipgloss"
8-)
9-
10-// RenderWelcome renders the wizard welcome screen.
11-func RenderWelcome(config domain.DesktopConfig) string {
12- var b strings.Builder
13-
14- title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
15- muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
16-
17- b.WriteString("\n")
18- b.WriteString(title.Render("ArchUp Desktop Wizard"))
19- b.WriteString("\n\n")
20- b.WriteString("This wizard will install a Wayland desktop and apply defaults.\n")
21- b.WriteString("You can re-run it safely to reconfigure.\n\n")
22- b.WriteString(muted.Render("Default compositor: " + string(config.Compositor) + "\n"))
23- b.WriteString(muted.Render("Press Enter to begin or Ctrl+C to exit."))
24- return b.String()
25-}
D internal/wizard/interfaces/tui/wizard_app.go +0 -484
@@ -1,484 +0,0 @@
1-package tui
2-
3-import (
4- "context"
5- "time"
6-
7- "github.com/bnema/archup/internal/domain/ports"
8- "github.com/bnema/archup/internal/wizard/application/services"
9- "github.com/bnema/archup/internal/wizard/domain"
10- "github.com/bnema/archup/internal/wizard/interfaces/tui/views"
11- tea "github.com/charmbracelet/bubbletea"
12-)
13-
14-// App is the wizard TUI model.
15-type App struct {
16- service *services.WizardService
17- logger ports.Logger
18- config domain.DesktopConfig
19- screen Screen
20- errMsg string
21-
22- compositorIndex int
23- monitors []services.MonitorOutput
24- monitorConfigs []services.MonitorConfig
25- monitorSelected int
26- monitorModeIdx map[string]int
27- monitorDirty bool
28- monitorApplySeq int
29- monitorApplying bool
30- monitorErr string
31-}
32-
33-// Screen represents wizard screens.
34-type Screen string
35-
36-const (
37- ScreenWelcome Screen = "welcome"
38- ScreenCompositor Screen = "compositor"
39- ScreenSDDM Screen = "sddm"
40- ScreenOptional Screen = "optional"
41- ScreenConfirm Screen = "confirm"
42- ScreenInstall Screen = "install"
43- ScreenMonitor Screen = "monitor"
44- ScreenConfig Screen = "config"
45- ScreenComplete Screen = "complete"
46-)
47-
48-// NewApp creates a new wizard TUI app.
49-func NewApp(service *services.WizardService, logger ports.Logger) *App {
50- return &App{
51- service: service,
52- logger: logger,
53- config: domain.NewDesktopConfig(),
54- screen: ScreenWelcome,
55- }
56-}
57-
58-// Init initializes the TUI.
59-func (a *App) Init() tea.Cmd {
60- return nil
61-}
62-
63-// Update handles input events.
64-func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65- switch msg := msg.(type) {
66- case wizardInstallMsg:
67- if msg.err != nil {
68- a.errMsg = msg.err.Error()
69- a.screen = ScreenComplete
70- return a, nil
71- }
72- a.screen = ScreenMonitor
73- return a, a.detectMonitorsCmd()
74- case wizardMonitorMsg:
75- if msg.err != nil {
76- a.errMsg = msg.err.Error()
77- a.screen = ScreenComplete
78- return a, nil
79- }
80- a.monitors = msg.monitors
81- a.monitorConfigs = buildMonitorConfigs(msg.monitors)
82- a.monitorModeIdx = buildMonitorModeIndex(msg.monitors)
83- a.monitorSelected = 0
84- a.monitorDirty = false
85- a.monitorApplying = false
86- a.monitorErr = ""
87- return a, nil
88- case monitorApplyTickMsg:
89- if msg.seq != a.monitorApplySeq || !a.monitorDirty {
90- return a, nil
91- }
92- a.monitorDirty = false
93- a.monitorApplying = true
94- return a, a.applyMonitorConfigCmd(msg.seq)
95- case monitorAppliedMsg:
96- if msg.seq != a.monitorApplySeq {
97- return a, nil
98- }
99- a.monitorApplying = false
100- a.monitorErr = ""
101- if msg.err != nil {
102- a.monitorErr = msg.err.Error()
103- a.monitorDirty = true
104- return a, a.scheduleMonitorApply()
105- }
106- return a, nil
107- case wizardConfigMsg:
108- if msg.err != nil {
109- a.errMsg = msg.err.Error()
110- }
111- a.screen = ScreenComplete
112- return a, nil
113- case tea.KeyMsg:
114- return a.handleKey(msg)
115- }
116- return a, nil
117-}
118-
119-// View renders the wizard screen.
120-func (a *App) View() string {
121- switch a.screen {
122- case ScreenWelcome:
123- return views.RenderWelcome(a.config)
124- case ScreenCompositor:
125- return views.RenderCompositor(a.config, a.compositorIndex)
126- case ScreenSDDM:
127- return views.RenderSDDM(a.config)
128- case ScreenOptional:
129- return views.RenderOptional(a.config)
130- case ScreenConfirm:
131- return views.RenderConfirm(a.config)
132- case ScreenInstall:
133- return views.RenderInstall()
134- case ScreenMonitor:
135- return views.RenderMonitors(a.monitors, a.monitorConfigs, a.monitorSelected, a.monitorApplying, a.monitorDirty, a.monitorErr)
136- case ScreenConfig:
137- return views.RenderApplyConfig()
138- case ScreenComplete:
139- return views.RenderComplete(a.config, a.errMsg)
140- default:
141- return views.RenderWelcome(a.config)
142- }
143-}
144-
145-func (a *App) startCmd() tea.Cmd {
146- return func() tea.Msg {
147- err := a.service.Install(context.Background(), a.config)
148- if err != nil {
149- a.logger.Error("Wizard start failed", "error", err)
150- }
151- return wizardInstallMsg{err: err}
152- }
153-}
154-
155-func (a *App) detectMonitorsCmd() tea.Cmd {
156- return func() tea.Msg {
157- monitors, err := a.service.DetectMonitors(context.Background())
158- if err != nil {
159- a.logger.Error("Monitor detection failed", "error", err)
160- }
161- return wizardMonitorMsg{monitors: monitors, err: err}
162- }
163-}
164-
165-func (a *App) applyConfigCmd() tea.Cmd {
166- return func() tea.Msg {
167- err := a.service.ApplyConfigWithMonitors(a.config, a.monitorConfigs)
168- if err != nil {
169- a.logger.Error("Wizard config apply failed", "error", err)
170- }
171- return wizardConfigMsg{err: err}
172- }
173-}
174-
175-func (a *App) applyMonitorConfigCmd(seq int) tea.Cmd {
176- configs := append([]services.MonitorConfig{}, a.monitorConfigs...)
177- return func() tea.Msg {
178- err := a.service.ApplyMonitorConfig(context.Background(), configs)
179- return monitorAppliedMsg{seq: seq, err: err}
180- }
181-}
182-
183-func (a *App) scheduleMonitorApply() tea.Cmd {
184- a.monitorApplySeq++
185- seq := a.monitorApplySeq
186- return tea.Tick(250*time.Millisecond, func(time.Time) tea.Msg {
187- return monitorApplyTickMsg{seq: seq}
188- })
189-}
190-
191-func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
192- switch msg.String() {
193- case "q", "ctrl+c":
194- return a, tea.Quit
195- }
196-
197- switch a.screen {
198- case ScreenWelcome:
199- if msg.String() == "enter" {
200- a.screen = ScreenCompositor
201- }
202- case ScreenCompositor:
203- switch msg.String() {
204- case "up", "shift+tab":
205- a.moveCompositor(-1)
206- case "down", "tab":
207- a.moveCompositor(1)
208- case "enter":
209- a.applyCompositorSelection()
210- a.screen = ScreenSDDM
211- }
212- case ScreenSDDM:
213- switch msg.String() {
214- case " ":
215- a.config.EnableSDDM = !a.config.EnableSDDM
216- if !a.config.EnableSDDM {
217- a.config.AutoLogin = false
218- return a, nil
219- }
220- case "a":
221- if a.config.EnableSDDM {
222- a.config.AutoLogin = !a.config.AutoLogin
223- }
224- case "enter":
225- a.screen = ScreenOptional
226- }
227- case ScreenOptional:
228- switch msg.String() {
229- case " ":
230- a.config.InstallCliphist = !a.config.InstallCliphist
231- case "enter":
232- a.screen = ScreenConfirm
233- }
234- case ScreenConfirm:
235- switch msg.String() {
236- case "enter":
237- a.screen = ScreenInstall
238- return a, a.startCmd()
239- case "b":
240- a.screen = ScreenOptional
241- }
242- case ScreenInstall:
243- return a, nil
244- case ScreenMonitor:
245- return a.handleMonitorKey(msg)
246- case ScreenConfig:
247- return a, nil
248- case ScreenComplete:
249- if msg.String() == "enter" {
250- return a, tea.Quit
251- }
252- }
253-
254- return a, nil
255-}
256-
257-func (a *App) moveCompositor(delta int) {
258- options := compositorOptions()
259- count := len(options)
260- if count == 0 {
261- return
262- }
263- idx := (a.compositorIndex + delta) % count
264- if idx < 0 {
265- idx = count - 1
266- }
267- a.compositorIndex = idx
268-}
269-
270-func (a *App) applyCompositorSelection() {
271- options := compositorOptions()
272- if len(options) == 0 {
273- return
274- }
275- if a.compositorIndex < 0 || a.compositorIndex >= len(options) {
276- a.compositorIndex = 0
277- }
278- a.config.Compositor = options[a.compositorIndex]
279-}
280-
281-func compositorOptions() []domain.Compositor {
282- return []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
283-}
284-
285-func buildMonitorConfigs(monitors []services.MonitorOutput) []services.MonitorConfig {
286- configs := make([]services.MonitorConfig, 0, len(monitors))
287- for _, monitor := range monitors {
288- cfg := services.MonitorConfig{
289- Name: monitor.Name,
290- Enabled: monitor.Enabled,
291- Scale: 1,
292- }
293- if monitor.CurrentMode != nil {
294- cfg.Width = monitor.CurrentMode.Width
295- cfg.Height = monitor.CurrentMode.Height
296- cfg.Refresh = monitor.CurrentMode.Refresh
297- }
298- configs = append(configs, cfg)
299- }
300- return configs
301-}
302-
303-func buildMonitorModeIndex(monitors []services.MonitorOutput) map[string]int {
304- index := make(map[string]int, len(monitors))
305- for _, monitor := range monitors {
306- idx := 0
307- if monitor.CurrentMode != nil {
308- idx = findModeIndex(monitor.Modes, *monitor.CurrentMode)
309- }
310- index[monitor.Name] = idx
311- }
312- return index
313-}
314-
315-func findModeIndex(modes []services.MonitorMode, current services.MonitorMode) int {
316- for i, mode := range modes {
317- if mode.Width == current.Width && mode.Height == current.Height && floatEquals(mode.Refresh, current.Refresh) {
318- return i
319- }
320- }
321- return 0
322-}
323-
324-func floatEquals(a, b float64) bool {
325- if a == b {
326- return true
327- }
328- if a > b {
329- return a-b < 0.05
330- }
331- return b-a < 0.05
332-}
333-
334-func (a *App) cycleMonitorMode(delta int) {
335- selected := a.selectedMonitor()
336- if selected == nil {
337- return
338- }
339- var modes []services.MonitorMode
340- for _, monitor := range a.monitors {
341- if monitor.Name == selected.Name {
342- modes = monitor.Modes
343- break
344- }
345- }
346- if len(modes) == 0 {
347- return
348- }
349- current := a.monitorModeIdx[selected.Name]
350- current = (current + delta) % len(modes)
351- if current < 0 {
352- current = len(modes) - 1
353- }
354- a.monitorModeIdx[selected.Name] = current
355- mode := modes[current]
356- selected.Width = mode.Width
357- selected.Height = mode.Height
358- selected.Refresh = mode.Refresh
359-}
360-
361-func (a *App) handleMonitorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
362- switch msg.String() {
363- case "enter":
364- a.screen = ScreenConfig
365- return a, a.applyConfigCmd()
366- case "up", "shift+tab":
367- a.monitorSelected = max(0, a.monitorSelected-1)
368- case "down", "tab":
369- if len(a.monitorConfigs) > 0 {
370- a.monitorSelected = min(len(a.monitorConfigs)-1, a.monitorSelected+1)
371- }
372- case " ":
373- if a.selectedMonitor() != nil {
374- a.selectedMonitor().Enabled = !a.selectedMonitor().Enabled
375- return a, a.markMonitorDirty()
376- }
377- case "m":
378- if a.selectedMonitor() != nil {
379- a.cycleMonitorMode(1)
380- return a, a.markMonitorDirty()
381- }
382- case "shift+m":
383- if a.selectedMonitor() != nil {
384- a.cycleMonitorMode(-1)
385- return a, a.markMonitorDirty()
386- }
387- case "left", "h":
388- if a.selectedMonitor() != nil {
389- a.selectedMonitor().PosX -= 50
390- return a, a.markMonitorDirty()
391- }
392- case "right", "l":
393- if a.selectedMonitor() != nil {
394- a.selectedMonitor().PosX += 50
395- return a, a.markMonitorDirty()
396- }
397- case "k":
398- if a.selectedMonitor() != nil {
399- a.selectedMonitor().PosY -= 50
400- return a, a.markMonitorDirty()
401- }
402- case "j":
403- if a.selectedMonitor() != nil {
404- a.selectedMonitor().PosY += 50
405- return a, a.markMonitorDirty()
406- }
407- case "r":
408- if a.selectedMonitor() != nil {
409- a.selectedMonitor().PosX = 0
410- a.selectedMonitor().PosY = 0
411- return a, a.markMonitorDirty()
412- }
413- case "+", "=":
414- if a.selectedMonitor() != nil {
415- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale + 0.1)
416- return a, a.markMonitorDirty()
417- }
418- case "-", "_":
419- if a.selectedMonitor() != nil {
420- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale - 0.1)
421- return a, a.markMonitorDirty()
422- }
423- }
424-
425- return a, nil
426-}
427-
428-func (a *App) markMonitorDirty() tea.Cmd {
429- a.monitorDirty = true
430- return a.scheduleMonitorApply()
431-}
432-
433-func (a *App) selectedMonitor() *services.MonitorConfig {
434- if a.monitorSelected < 0 || a.monitorSelected >= len(a.monitorConfigs) {
435- return nil
436- }
437- return &a.monitorConfigs[a.monitorSelected]
438-}
439-
440-func clampScale(scale float64) float64 {
441- if scale < 0.5 {
442- return 0.5
443- }
444- if scale > 3.0 {
445- return 3.0
446- }
447- return scale
448-}
449-
450-func min(a, b int) int {
451- if a < b {
452- return a
453- }
454- return b
455-}
456-
457-func max(a, b int) int {
458- if a > b {
459- return a
460- }
461- return b
462-}
463-
464-type wizardInstallMsg struct {
465- err error
466-}
467-
468-type wizardMonitorMsg struct {
469- monitors []services.MonitorOutput
470- err error
471-}
472-
473-type wizardConfigMsg struct {
474- err error
475-}
476-
477-type monitorApplyTickMsg struct {
478- seq int
479-}
480-
481-type monitorAppliedMsg struct {
482- seq int
483- err error
484-}
@@ -1,484 +0,0 @@
1-package tui
2-
3-import (
4- "context"
5- "time"
6-
7- "github.com/bnema/archup/internal/domain/ports"
8- "github.com/bnema/archup/internal/wizard/application/services"
9- "github.com/bnema/archup/internal/wizard/domain"
10- "github.com/bnema/archup/internal/wizard/interfaces/tui/views"
11- tea "github.com/charmbracelet/bubbletea"
12-)
13-
14-// App is the wizard TUI model.
15-type App struct {
16- service *services.WizardService
17- logger ports.Logger
18- config domain.DesktopConfig
19- screen Screen
20- errMsg string
21-
22- compositorIndex int
23- monitors []services.MonitorOutput
24- monitorConfigs []services.MonitorConfig
25- monitorSelected int
26- monitorModeIdx map[string]int
27- monitorDirty bool
28- monitorApplySeq int
29- monitorApplying bool
30- monitorErr string
31-}
32-
33-// Screen represents wizard screens.
34-type Screen string
35-
36-const (
37- ScreenWelcome Screen = "welcome"
38- ScreenCompositor Screen = "compositor"
39- ScreenSDDM Screen = "sddm"
40- ScreenOptional Screen = "optional"
41- ScreenConfirm Screen = "confirm"
42- ScreenInstall Screen = "install"
43- ScreenMonitor Screen = "monitor"
44- ScreenConfig Screen = "config"
45- ScreenComplete Screen = "complete"
46-)
47-
48-// NewApp creates a new wizard TUI app.
49-func NewApp(service *services.WizardService, logger ports.Logger) *App {
50- return &App{
51- service: service,
52- logger: logger,
53- config: domain.NewDesktopConfig(),
54- screen: ScreenWelcome,
55- }
56-}
57-
58-// Init initializes the TUI.
59-func (a *App) Init() tea.Cmd {
60- return nil
61-}
62-
63-// Update handles input events.
64-func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65- switch msg := msg.(type) {
66- case wizardInstallMsg:
67- if msg.err != nil {
68- a.errMsg = msg.err.Error()
69- a.screen = ScreenComplete
70- return a, nil
71- }
72- a.screen = ScreenMonitor
73- return a, a.detectMonitorsCmd()
74- case wizardMonitorMsg:
75- if msg.err != nil {
76- a.errMsg = msg.err.Error()
77- a.screen = ScreenComplete
78- return a, nil
79- }
80- a.monitors = msg.monitors
81- a.monitorConfigs = buildMonitorConfigs(msg.monitors)
82- a.monitorModeIdx = buildMonitorModeIndex(msg.monitors)
83- a.monitorSelected = 0
84- a.monitorDirty = false
85- a.monitorApplying = false
86- a.monitorErr = ""
87- return a, nil
88- case monitorApplyTickMsg:
89- if msg.seq != a.monitorApplySeq || !a.monitorDirty {
90- return a, nil
91- }
92- a.monitorDirty = false
93- a.monitorApplying = true
94- return a, a.applyMonitorConfigCmd(msg.seq)
95- case monitorAppliedMsg:
96- if msg.seq != a.monitorApplySeq {
97- return a, nil
98- }
99- a.monitorApplying = false
100- a.monitorErr = ""
101- if msg.err != nil {
102- a.monitorErr = msg.err.Error()
103- a.monitorDirty = true
104- return a, a.scheduleMonitorApply()
105- }
106- return a, nil
107- case wizardConfigMsg:
108- if msg.err != nil {
109- a.errMsg = msg.err.Error()
110- }
111- a.screen = ScreenComplete
112- return a, nil
113- case tea.KeyMsg:
114- return a.handleKey(msg)
115- }
116- return a, nil
117-}
118-
119-// View renders the wizard screen.
120-func (a *App) View() string {
121- switch a.screen {
122- case ScreenWelcome:
123- return views.RenderWelcome(a.config)
124- case ScreenCompositor:
125- return views.RenderCompositor(a.config, a.compositorIndex)
126- case ScreenSDDM:
127- return views.RenderSDDM(a.config)
128- case ScreenOptional:
129- return views.RenderOptional(a.config)
130- case ScreenConfirm:
131- return views.RenderConfirm(a.config)
132- case ScreenInstall:
133- return views.RenderInstall()
134- case ScreenMonitor:
135- return views.RenderMonitors(a.monitors, a.monitorConfigs, a.monitorSelected, a.monitorApplying, a.monitorDirty, a.monitorErr)
136- case ScreenConfig:
137- return views.RenderApplyConfig()
138- case ScreenComplete:
139- return views.RenderComplete(a.config, a.errMsg)
140- default:
141- return views.RenderWelcome(a.config)
142- }
143-}
144-
145-func (a *App) startCmd() tea.Cmd {
146- return func() tea.Msg {
147- err := a.service.Install(context.Background(), a.config)
148- if err != nil {
149- a.logger.Error("Wizard start failed", "error", err)
150- }
151- return wizardInstallMsg{err: err}
152- }
153-}
154-
155-func (a *App) detectMonitorsCmd() tea.Cmd {
156- return func() tea.Msg {
157- monitors, err := a.service.DetectMonitors(context.Background())
158- if err != nil {
159- a.logger.Error("Monitor detection failed", "error", err)
160- }
161- return wizardMonitorMsg{monitors: monitors, err: err}
162- }
163-}
164-
165-func (a *App) applyConfigCmd() tea.Cmd {
166- return func() tea.Msg {
167- err := a.service.ApplyConfigWithMonitors(a.config, a.monitorConfigs)
168- if err != nil {
169- a.logger.Error("Wizard config apply failed", "error", err)
170- }
171- return wizardConfigMsg{err: err}
172- }
173-}
174-
175-func (a *App) applyMonitorConfigCmd(seq int) tea.Cmd {
176- configs := append([]services.MonitorConfig{}, a.monitorConfigs...)
177- return func() tea.Msg {
178- err := a.service.ApplyMonitorConfig(context.Background(), configs)
179- return monitorAppliedMsg{seq: seq, err: err}
180- }
181-}
182-
183-func (a *App) scheduleMonitorApply() tea.Cmd {
184- a.monitorApplySeq++
185- seq := a.monitorApplySeq
186- return tea.Tick(250*time.Millisecond, func(time.Time) tea.Msg {
187- return monitorApplyTickMsg{seq: seq}
188- })
189-}
190-
191-func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
192- switch msg.String() {
193- case "q", "ctrl+c":
194- return a, tea.Quit
195- }
196-
197- switch a.screen {
198- case ScreenWelcome:
199- if msg.String() == "enter" {
200- a.screen = ScreenCompositor
201- }
202- case ScreenCompositor:
203- switch msg.String() {
204- case "up", "shift+tab":
205- a.moveCompositor(-1)
206- case "down", "tab":
207- a.moveCompositor(1)
208- case "enter":
209- a.applyCompositorSelection()
210- a.screen = ScreenSDDM
211- }
212- case ScreenSDDM:
213- switch msg.String() {
214- case " ":
215- a.config.EnableSDDM = !a.config.EnableSDDM
216- if !a.config.EnableSDDM {
217- a.config.AutoLogin = false
218- return a, nil
219- }
220- case "a":
221- if a.config.EnableSDDM {
222- a.config.AutoLogin = !a.config.AutoLogin
223- }
224- case "enter":
225- a.screen = ScreenOptional
226- }
227- case ScreenOptional:
228- switch msg.String() {
229- case " ":
230- a.config.InstallCliphist = !a.config.InstallCliphist
231- case "enter":
232- a.screen = ScreenConfirm
233- }
234- case ScreenConfirm:
235- switch msg.String() {
236- case "enter":
237- a.screen = ScreenInstall
238- return a, a.startCmd()
239- case "b":
240- a.screen = ScreenOptional
241- }
242- case ScreenInstall:
243- return a, nil
244- case ScreenMonitor:
245- return a.handleMonitorKey(msg)
246- case ScreenConfig:
247- return a, nil
248- case ScreenComplete:
249- if msg.String() == "enter" {
250- return a, tea.Quit
251- }
252- }
253-
254- return a, nil
255-}
256-
257-func (a *App) moveCompositor(delta int) {
258- options := compositorOptions()
259- count := len(options)
260- if count == 0 {
261- return
262- }
263- idx := (a.compositorIndex + delta) % count
264- if idx < 0 {
265- idx = count - 1
266- }
267- a.compositorIndex = idx
268-}
269-
270-func (a *App) applyCompositorSelection() {
271- options := compositorOptions()
272- if len(options) == 0 {
273- return
274- }
275- if a.compositorIndex < 0 || a.compositorIndex >= len(options) {
276- a.compositorIndex = 0
277- }
278- a.config.Compositor = options[a.compositorIndex]
279-}
280-
281-func compositorOptions() []domain.Compositor {
282- return []domain.Compositor{domain.CompositorNiri, domain.CompositorHyprland}
283-}
284-
285-func buildMonitorConfigs(monitors []services.MonitorOutput) []services.MonitorConfig {
286- configs := make([]services.MonitorConfig, 0, len(monitors))
287- for _, monitor := range monitors {
288- cfg := services.MonitorConfig{
289- Name: monitor.Name,
290- Enabled: monitor.Enabled,
291- Scale: 1,
292- }
293- if monitor.CurrentMode != nil {
294- cfg.Width = monitor.CurrentMode.Width
295- cfg.Height = monitor.CurrentMode.Height
296- cfg.Refresh = monitor.CurrentMode.Refresh
297- }
298- configs = append(configs, cfg)
299- }
300- return configs
301-}
302-
303-func buildMonitorModeIndex(monitors []services.MonitorOutput) map[string]int {
304- index := make(map[string]int, len(monitors))
305- for _, monitor := range monitors {
306- idx := 0
307- if monitor.CurrentMode != nil {
308- idx = findModeIndex(monitor.Modes, *monitor.CurrentMode)
309- }
310- index[monitor.Name] = idx
311- }
312- return index
313-}
314-
315-func findModeIndex(modes []services.MonitorMode, current services.MonitorMode) int {
316- for i, mode := range modes {
317- if mode.Width == current.Width && mode.Height == current.Height && floatEquals(mode.Refresh, current.Refresh) {
318- return i
319- }
320- }
321- return 0
322-}
323-
324-func floatEquals(a, b float64) bool {
325- if a == b {
326- return true
327- }
328- if a > b {
329- return a-b < 0.05
330- }
331- return b-a < 0.05
332-}
333-
334-func (a *App) cycleMonitorMode(delta int) {
335- selected := a.selectedMonitor()
336- if selected == nil {
337- return
338- }
339- var modes []services.MonitorMode
340- for _, monitor := range a.monitors {
341- if monitor.Name == selected.Name {
342- modes = monitor.Modes
343- break
344- }
345- }
346- if len(modes) == 0 {
347- return
348- }
349- current := a.monitorModeIdx[selected.Name]
350- current = (current + delta) % len(modes)
351- if current < 0 {
352- current = len(modes) - 1
353- }
354- a.monitorModeIdx[selected.Name] = current
355- mode := modes[current]
356- selected.Width = mode.Width
357- selected.Height = mode.Height
358- selected.Refresh = mode.Refresh
359-}
360-
361-func (a *App) handleMonitorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
362- switch msg.String() {
363- case "enter":
364- a.screen = ScreenConfig
365- return a, a.applyConfigCmd()
366- case "up", "shift+tab":
367- a.monitorSelected = max(0, a.monitorSelected-1)
368- case "down", "tab":
369- if len(a.monitorConfigs) > 0 {
370- a.monitorSelected = min(len(a.monitorConfigs)-1, a.monitorSelected+1)
371- }
372- case " ":
373- if a.selectedMonitor() != nil {
374- a.selectedMonitor().Enabled = !a.selectedMonitor().Enabled
375- return a, a.markMonitorDirty()
376- }
377- case "m":
378- if a.selectedMonitor() != nil {
379- a.cycleMonitorMode(1)
380- return a, a.markMonitorDirty()
381- }
382- case "shift+m":
383- if a.selectedMonitor() != nil {
384- a.cycleMonitorMode(-1)
385- return a, a.markMonitorDirty()
386- }
387- case "left", "h":
388- if a.selectedMonitor() != nil {
389- a.selectedMonitor().PosX -= 50
390- return a, a.markMonitorDirty()
391- }
392- case "right", "l":
393- if a.selectedMonitor() != nil {
394- a.selectedMonitor().PosX += 50
395- return a, a.markMonitorDirty()
396- }
397- case "k":
398- if a.selectedMonitor() != nil {
399- a.selectedMonitor().PosY -= 50
400- return a, a.markMonitorDirty()
401- }
402- case "j":
403- if a.selectedMonitor() != nil {
404- a.selectedMonitor().PosY += 50
405- return a, a.markMonitorDirty()
406- }
407- case "r":
408- if a.selectedMonitor() != nil {
409- a.selectedMonitor().PosX = 0
410- a.selectedMonitor().PosY = 0
411- return a, a.markMonitorDirty()
412- }
413- case "+", "=":
414- if a.selectedMonitor() != nil {
415- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale + 0.1)
416- return a, a.markMonitorDirty()
417- }
418- case "-", "_":
419- if a.selectedMonitor() != nil {
420- a.selectedMonitor().Scale = clampScale(a.selectedMonitor().Scale - 0.1)
421- return a, a.markMonitorDirty()
422- }
423- }
424-
425- return a, nil
426-}
427-
428-func (a *App) markMonitorDirty() tea.Cmd {
429- a.monitorDirty = true
430- return a.scheduleMonitorApply()
431-}
432-
433-func (a *App) selectedMonitor() *services.MonitorConfig {
434- if a.monitorSelected < 0 || a.monitorSelected >= len(a.monitorConfigs) {
435- return nil
436- }
437- return &a.monitorConfigs[a.monitorSelected]
438-}
439-
440-func clampScale(scale float64) float64 {
441- if scale < 0.5 {
442- return 0.5
443- }
444- if scale > 3.0 {
445- return 3.0
446- }
447- return scale
448-}
449-
450-func min(a, b int) int {
451- if a < b {
452- return a
453- }
454- return b
455-}
456-
457-func max(a, b int) int {
458- if a > b {
459- return a
460- }
461- return b
462-}
463-
464-type wizardInstallMsg struct {
465- err error
466-}
467-
468-type wizardMonitorMsg struct {
469- monitors []services.MonitorOutput
470- err error
471-}
472-
473-type wizardConfigMsg struct {
474- err error
475-}
476-
477-type monitorApplyTickMsg struct {
478- seq int
479-}
480-
481-type monitorAppliedMsg struct {
482- seq int
483- err error
484-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close