detached 6ef60f4

feat(bootstrap): add GPU detection and disk selection to installation wizard

- Add BootstrapResult DTO for holding bootstrap phase results
- Add BootstrapHandler for GPU detection and selection
- Add GPU domain model with detection and validation logic
- Add GPUHandler for processing GPU selection in application layer
- Add TUI models for disk and GPU selection with proper data structures
- Add TUI views for disk and GPU selection with interactive UI components
- Add defaults package with modular package selection system
- Add default package lists for different components (compositors, desktops, GPU acceleration, etc.)
- Add wizard package for coordinating multi-step installation process
- Add new CLI commands (root, version, wizard, install, cleanup) for modular entry points
- Add waybar configuration with theme toggle script for desktop customization
commit
6ef60f4169451aa62726f3c4d25c9859ad1e8146
author
brice · · 2026-01-25 18:00 UTC
parent
d7fb909
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
36 files · +2691 -0
A cmd/cleanup.go +40 -0
@@ -0,0 +1,40 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/bnema/archup/internal/cleanup"
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/logger"
10+ "github.com/spf13/cobra"
11+)
12+
13+func init() {
14+ rootCmd.AddCommand(newCleanupCmd())
15+}
16+
17+func newCleanupCmd() *cobra.Command {
18+ return &cobra.Command{
19+ Use: "cleanup",
20+ Short: "Clean up installation artifacts",
21+ RunE: func(cmd *cobra.Command, args []string) error {
22+ oldLog, err := logger.New(config.DefaultLogPath, false)
23+ if err != nil {
24+ return fmt.Errorf("failed to create logger: %w", err)
25+ }
26+ defer func() {
27+ if err := oldLog.Close(); err != nil {
28+ fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
29+ }
30+ }()
31+
32+ if err := cleanup.Run(oldLog); err != nil {
33+ oldLog.Error("Cleanup failed", "error", err)
34+ return fmt.Errorf("cleanup failed: %w", err)
35+ }
36+
37+ return nil
38+ },
39+ }
40+}
@@ -0,0 +1,40 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/bnema/archup/internal/cleanup"
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/logger"
10+ "github.com/spf13/cobra"
11+)
12+
13+func init() {
14+ rootCmd.AddCommand(newCleanupCmd())
15+}
16+
17+func newCleanupCmd() *cobra.Command {
18+ return &cobra.Command{
19+ Use: "cleanup",
20+ Short: "Clean up installation artifacts",
21+ RunE: func(cmd *cobra.Command, args []string) error {
22+ oldLog, err := logger.New(config.DefaultLogPath, false)
23+ if err != nil {
24+ return fmt.Errorf("failed to create logger: %w", err)
25+ }
26+ defer func() {
27+ if err := oldLog.Close(); err != nil {
28+ fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
29+ }
30+ }()
31+
32+ if err := cleanup.Run(oldLog); err != nil {
33+ oldLog.Error("Cleanup failed", "error", err)
34+ return fmt.Errorf("cleanup failed: %w", err)
35+ }
36+
37+ return nil
38+ },
39+ }
40+}
A cmd/install.go +109 -0
@@ -0,0 +1,109 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ apphandlers "github.com/bnema/archup/internal/application/handlers"
8+ "github.com/bnema/archup/internal/application/services"
9+ "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/infrastructure/executor"
11+ "github.com/bnema/archup/internal/infrastructure/filesystem"
12+ infrahttp "github.com/bnema/archup/internal/infrastructure/http"
13+ infralogger "github.com/bnema/archup/internal/infrastructure/logger"
14+ "github.com/bnema/archup/internal/infrastructure/persistence"
15+ "github.com/bnema/archup/internal/interfaces/tui"
16+ "github.com/bnema/archup/internal/logger"
17+ tea "github.com/charmbracelet/bubbletea"
18+ "github.com/spf13/cobra"
19+)
20+
21+func init() {
22+ rootCmd.AddCommand(newInstallCmd())
23+}
24+
25+func newInstallCmd() *cobra.Command {
26+ var dryRun bool
27+ cmd := &cobra.Command{
28+ Use: "install",
29+ Short: "Run base system installer",
30+ RunE: func(cmd *cobra.Command, args []string) error {
31+ return runInstall(dryRun)
32+ },
33+ }
34+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show TUI but don't execute commands")
35+ return cmd
36+}
37+
38+func runInstall(dryRun bool) error {
39+ oldLog, err := logger.New(config.DefaultLogPath, dryRun)
40+ if err != nil {
41+ return fmt.Errorf("failed to create logger: %w", err)
42+ }
43+ defer func() {
44+ if err := oldLog.Close(); err != nil {
45+ fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
46+ }
47+ }()
48+
49+ if dryRun {
50+ oldLog.Info("Running in DRY-RUN mode - commands will be logged but not executed")
51+ }
52+
53+ cfg := config.NewConfig(version)
54+ oldLog.Info("Config initialized", "version", version, "raw_url", cfg.RawURL)
55+
56+ oldLog.Info("Initializing DDD architecture components")
57+
58+ slogAdapter := infralogger.NewSlogAdapter(oldLog.Slog())
59+
60+ fsAdapter := &filesystem.LocalFileSystem{}
61+ shellExec := executor.NewShellExecutor(slogAdapter)
62+ chrootExec := executor.NewChrootExecutor(slogAdapter)
63+ httpClient := infrahttp.NewHTTPClient()
64+ scriptExec := executor.NewScriptExecutor(fsAdapter, shellExec, config.DefaultInstallDir)
65+ repoAdapter, err := persistence.NewFileRepository(config.DefaultConfigPath)
66+ if err != nil {
67+ oldLog.Error("Failed to create repository adapter", "error", err)
68+ return fmt.Errorf("create repository adapter: %w", err)
69+ }
70+
71+ bootstrapHandler := apphandlers.NewBootstrapHandler(fsAdapter, httpClient, slogAdapter, cfg.RepoURL, cfg.RawURL)
72+ preflightHandler := apphandlers.NewPreflightHandler(fsAdapter, shellExec, slogAdapter)
73+ partitionHandler := apphandlers.NewPartitionHandler(shellExec, slogAdapter)
74+ baseHandler := apphandlers.NewInstallBaseHandler(fsAdapter, shellExec, chrootExec, slogAdapter)
75+ configHandler := apphandlers.NewConfigureSystemHandler(fsAdapter, chrootExec, slogAdapter)
76+ bootloaderHandler := apphandlers.NewBootloaderHandler(fsAdapter, shellExec, chrootExec, slogAdapter)
77+ reposHandler := apphandlers.NewReposHandler(fsAdapter, chrootExec, slogAdapter)
78+ postInstallHandler := apphandlers.NewPostInstallHandler(fsAdapter, httpClient, chrootExec, scriptExec, slogAdapter, cfg.RawURL)
79+
80+ installService := services.NewInstallationService(
81+ repoAdapter,
82+ slogAdapter,
83+ bootstrapHandler,
84+ preflightHandler,
85+ partitionHandler,
86+ baseHandler,
87+ configHandler,
88+ bootloaderHandler,
89+ reposHandler,
90+ postInstallHandler,
91+ )
92+
93+ gpuHandler := apphandlers.NewGPUHandler(shellExec, slogAdapter)
94+ tuiApp := tui.NewApp(installService, installService.Tracker(), gpuHandler, slogAdapter)
95+
96+ oldLog.Info("Starting TUI application", "version", version)
97+ p := tea.NewProgram(tuiApp, tea.WithAltScreen())
98+ tuiApp.SetProgram(p)
99+ if _, err := p.Run(); err != nil {
100+ oldLog.Error("Error running installer", "error", err)
101+ return fmt.Errorf("run installer: %w", err)
102+ }
103+
104+ if err := tuiApp.Close(); err != nil {
105+ oldLog.Error("Error closing TUI app", "error", err)
106+ }
107+
108+ return nil
109+}
@@ -0,0 +1,109 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ apphandlers "github.com/bnema/archup/internal/application/handlers"
8+ "github.com/bnema/archup/internal/application/services"
9+ "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/infrastructure/executor"
11+ "github.com/bnema/archup/internal/infrastructure/filesystem"
12+ infrahttp "github.com/bnema/archup/internal/infrastructure/http"
13+ infralogger "github.com/bnema/archup/internal/infrastructure/logger"
14+ "github.com/bnema/archup/internal/infrastructure/persistence"
15+ "github.com/bnema/archup/internal/interfaces/tui"
16+ "github.com/bnema/archup/internal/logger"
17+ tea "github.com/charmbracelet/bubbletea"
18+ "github.com/spf13/cobra"
19+)
20+
21+func init() {
22+ rootCmd.AddCommand(newInstallCmd())
23+}
24+
25+func newInstallCmd() *cobra.Command {
26+ var dryRun bool
27+ cmd := &cobra.Command{
28+ Use: "install",
29+ Short: "Run base system installer",
30+ RunE: func(cmd *cobra.Command, args []string) error {
31+ return runInstall(dryRun)
32+ },
33+ }
34+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show TUI but don't execute commands")
35+ return cmd
36+}
37+
38+func runInstall(dryRun bool) error {
39+ oldLog, err := logger.New(config.DefaultLogPath, dryRun)
40+ if err != nil {
41+ return fmt.Errorf("failed to create logger: %w", err)
42+ }
43+ defer func() {
44+ if err := oldLog.Close(); err != nil {
45+ fmt.Fprintf(os.Stderr, "Failed to close logger: %v\n", err)
46+ }
47+ }()
48+
49+ if dryRun {
50+ oldLog.Info("Running in DRY-RUN mode - commands will be logged but not executed")
51+ }
52+
53+ cfg := config.NewConfig(version)
54+ oldLog.Info("Config initialized", "version", version, "raw_url", cfg.RawURL)
55+
56+ oldLog.Info("Initializing DDD architecture components")
57+
58+ slogAdapter := infralogger.NewSlogAdapter(oldLog.Slog())
59+
60+ fsAdapter := &filesystem.LocalFileSystem{}
61+ shellExec := executor.NewShellExecutor(slogAdapter)
62+ chrootExec := executor.NewChrootExecutor(slogAdapter)
63+ httpClient := infrahttp.NewHTTPClient()
64+ scriptExec := executor.NewScriptExecutor(fsAdapter, shellExec, config.DefaultInstallDir)
65+ repoAdapter, err := persistence.NewFileRepository(config.DefaultConfigPath)
66+ if err != nil {
67+ oldLog.Error("Failed to create repository adapter", "error", err)
68+ return fmt.Errorf("create repository adapter: %w", err)
69+ }
70+
71+ bootstrapHandler := apphandlers.NewBootstrapHandler(fsAdapter, httpClient, slogAdapter, cfg.RepoURL, cfg.RawURL)
72+ preflightHandler := apphandlers.NewPreflightHandler(fsAdapter, shellExec, slogAdapter)
73+ partitionHandler := apphandlers.NewPartitionHandler(shellExec, slogAdapter)
74+ baseHandler := apphandlers.NewInstallBaseHandler(fsAdapter, shellExec, chrootExec, slogAdapter)
75+ configHandler := apphandlers.NewConfigureSystemHandler(fsAdapter, chrootExec, slogAdapter)
76+ bootloaderHandler := apphandlers.NewBootloaderHandler(fsAdapter, shellExec, chrootExec, slogAdapter)
77+ reposHandler := apphandlers.NewReposHandler(fsAdapter, chrootExec, slogAdapter)
78+ postInstallHandler := apphandlers.NewPostInstallHandler(fsAdapter, httpClient, chrootExec, scriptExec, slogAdapter, cfg.RawURL)
79+
80+ installService := services.NewInstallationService(
81+ repoAdapter,
82+ slogAdapter,
83+ bootstrapHandler,
84+ preflightHandler,
85+ partitionHandler,
86+ baseHandler,
87+ configHandler,
88+ bootloaderHandler,
89+ reposHandler,
90+ postInstallHandler,
91+ )
92+
93+ gpuHandler := apphandlers.NewGPUHandler(shellExec, slogAdapter)
94+ tuiApp := tui.NewApp(installService, installService.Tracker(), gpuHandler, slogAdapter)
95+
96+ oldLog.Info("Starting TUI application", "version", version)
97+ p := tea.NewProgram(tuiApp, tea.WithAltScreen())
98+ tuiApp.SetProgram(p)
99+ if _, err := p.Run(); err != nil {
100+ oldLog.Error("Error running installer", "error", err)
101+ return fmt.Errorf("run installer: %w", err)
102+ }
103+
104+ if err := tuiApp.Close(); err != nil {
105+ oldLog.Error("Error closing TUI app", "error", err)
106+ }
107+
108+ return nil
109+}
A cmd/root.go +26 -0
@@ -0,0 +1,26 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/spf13/cobra"
8+)
9+
10+// version is set via ldflags at build time
11+// Example: go build -ldflags "-X github.com/bnema/archup/cmd.version=v0.3.0"
12+var version = "dev"
13+
14+var rootCmd = &cobra.Command{
15+ Use: "archup",
16+ Short: "ArchUp installer",
17+ Long: "ArchUp installs and configures Arch Linux systems.",
18+ SilenceUsage: true,
19+}
20+
21+func Execute() {
22+ if err := rootCmd.Execute(); err != nil {
23+ fmt.Fprintln(os.Stderr, err)
24+ os.Exit(1)
25+ }
26+}
@@ -0,0 +1,26 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/spf13/cobra"
8+)
9+
10+// version is set via ldflags at build time
11+// Example: go build -ldflags "-X github.com/bnema/archup/cmd.version=v0.3.0"
12+var version = "dev"
13+
14+var rootCmd = &cobra.Command{
15+ Use: "archup",
16+ Short: "ArchUp installer",
17+ Long: "ArchUp installs and configures Arch Linux systems.",
18+ SilenceUsage: true,
19+}
20+
21+func Execute() {
22+ if err := rootCmd.Execute(); err != nil {
23+ fmt.Fprintln(os.Stderr, err)
24+ os.Exit(1)
25+ }
26+}
A cmd/version.go +17 -0
@@ -0,0 +1,17 @@
1+package cmd
2+
3+import "github.com/spf13/cobra"
4+
5+func init() {
6+ rootCmd.AddCommand(newVersionCmd())
7+}
8+
9+func newVersionCmd() *cobra.Command {
10+ return &cobra.Command{
11+ Use: "version",
12+ Short: "Show version",
13+ Run: func(cmd *cobra.Command, args []string) {
14+ cmd.Printf("archup %s\n", version)
15+ },
16+ }
17+}
@@ -0,0 +1,17 @@
1+package cmd
2+
3+import "github.com/spf13/cobra"
4+
5+func init() {
6+ rootCmd.AddCommand(newVersionCmd())
7+}
8+
9+func newVersionCmd() *cobra.Command {
10+ return &cobra.Command{
11+ Use: "version",
12+ Short: "Show version",
13+ Run: func(cmd *cobra.Command, args []string) {
14+ cmd.Printf("archup %s\n", version)
15+ },
16+ }
17+}
A cmd/wizard.go +65 -0
@@ -0,0 +1,65 @@
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+}
@@ -0,0 +1,65 @@
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+}
A install/configs/waybar/config.json +32 -0
@@ -0,0 +1,32 @@
1+{
2+ "layer": "top",
3+ "position": "top",
4+ "modules-left": ["workspaces", "window"],
5+ "modules-center": ["clock"],
6+ "modules-right": ["idle_inhibitor", "custom/theme", "tray", "network", "pulseaudio", "cpu", "memory"],
7+ "clock": {
8+ "format": "{:%a %b %d %H:%M}"
9+ },
10+ "cpu": {
11+ "format": "CPU {usage}%"
12+ },
13+ "memory": {
14+ "format": "MEM {used:0.1f}G"
15+ },
16+ "network": {
17+ "format-wifi": "{essid} ({signalStrength}%)",
18+ "format-ethernet": "{ifname}",
19+ "format-disconnected": "offline"
20+ },
21+ "pulseaudio": {
22+ "format": "{volume}%",
23+ "format-muted": "muted"
24+ },
25+ "custom/theme": {
26+ "format": "\uF186",
27+ "tooltip": "Toggle theme",
28+ "on-click": "~/.config/waybar/scripts/theme-toggle.sh",
29+ "on-click-right": "~/.config/waybar/scripts/theme-toggle.sh",
30+ "interval": 0
31+ }
32+}
@@ -0,0 +1,32 @@
1+{
2+ "layer": "top",
3+ "position": "top",
4+ "modules-left": ["workspaces", "window"],
5+ "modules-center": ["clock"],
6+ "modules-right": ["idle_inhibitor", "custom/theme", "tray", "network", "pulseaudio", "cpu", "memory"],
7+ "clock": {
8+ "format": "{:%a %b %d %H:%M}"
9+ },
10+ "cpu": {
11+ "format": "CPU {usage}%"
12+ },
13+ "memory": {
14+ "format": "MEM {used:0.1f}G"
15+ },
16+ "network": {
17+ "format-wifi": "{essid} ({signalStrength}%)",
18+ "format-ethernet": "{ifname}",
19+ "format-disconnected": "offline"
20+ },
21+ "pulseaudio": {
22+ "format": "{volume}%",
23+ "format-muted": "muted"
24+ },
25+ "custom/theme": {
26+ "format": "\uF186",
27+ "tooltip": "Toggle theme",
28+ "on-click": "~/.config/waybar/scripts/theme-toggle.sh",
29+ "on-click-right": "~/.config/waybar/scripts/theme-toggle.sh",
30+ "interval": 0
31+ }
32+}
A install/configs/waybar/theme-toggle.sh +14 -0
@@ -0,0 +1,14 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+current=$(gsettings get org.gnome.desktop.interface color-scheme | tr -d "'")
5+
6+if [[ "$current" == "prefer-dark" ]]; then
7+ gsettings set org.gnome.desktop.interface color-scheme default
8+ gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3
9+else
10+ gsettings set org.gnome.desktop.interface color-scheme prefer-dark
11+ gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3-dark
12+fi
13+
14+exit 0
@@ -0,0 +1,14 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+current=$(gsettings get org.gnome.desktop.interface color-scheme | tr -d "'")
5+
6+if [[ "$current" == "prefer-dark" ]]; then
7+ gsettings set org.gnome.desktop.interface color-scheme default
8+ gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3
9+else
10+ gsettings set org.gnome.desktop.interface color-scheme prefer-dark
11+ gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3-dark
12+fi
13+
14+exit 0
A internal/application/dto/bootstrap_result.go +9 -0
@@ -0,0 +1,9 @@
1+package dto
2+
3+// BootstrapResult contains the result of the bootstrap phase
4+type BootstrapResult struct {
5+ Success bool
6+ InstallDir string
7+ Method string // "clone" or "download"
8+ ErrorDetail string
9+}
@@ -0,0 +1,9 @@
1+package dto
2+
3+// BootstrapResult contains the result of the bootstrap phase
4+type BootstrapResult struct {
5+ Success bool
6+ InstallDir string
7+ Method string // "clone" or "download"
8+ ErrorDetail string
9+}
A internal/application/handlers/bootstrap_handler.go +180 -0
@@ -0,0 +1,180 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "path/filepath"
8+
9+ "github.com/bnema/archup/internal/application/dto"
10+ "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/domain/ports"
12+)
13+
14+// BootstrapHandler handles downloading/cloning install files
15+type BootstrapHandler struct {
16+ fs ports.FileSystem
17+ httpClient ports.HTTPClient
18+ logger ports.Logger
19+ repoURL string
20+ rawURL string
21+}
22+
23+// NewBootstrapHandler creates a new bootstrap handler
24+func NewBootstrapHandler(fs ports.FileSystem, httpClient ports.HTTPClient, logger ports.Logger, repoURL, rawURL string) *BootstrapHandler {
25+ return &BootstrapHandler{
26+ fs: fs,
27+ httpClient: httpClient,
28+ logger: logger,
29+ repoURL: repoURL,
30+ rawURL: rawURL,
31+ }
32+}
33+
34+// Handle prepares install files by cloning repo or downloading
35+func (h *BootstrapHandler) Handle(ctx context.Context) (*dto.BootstrapResult, error) {
36+ h.logger.Info("Starting bootstrap")
37+
38+ result := &dto.BootstrapResult{
39+ Success: false,
40+ InstallDir: config.DefaultInstallDir,
41+ ErrorDetail: "",
42+ }
43+
44+ // Create install directory
45+ if err := h.fs.MkdirAll(config.DefaultInstallDir, 0755); err != nil {
46+ result.ErrorDetail = fmt.Sprintf("Failed to create install directory: %v", err)
47+ return result, err
48+ }
49+
50+ // Try to clone repo first
51+ if err := h.cloneRepo(ctx); err == nil {
52+ if err := h.copyRepoFiles(); err != nil {
53+ h.logger.Warn("Failed to copy repo files", "error", err)
54+ result.ErrorDetail = fmt.Sprintf("Failed to copy repo files: %v", err)
55+ return result, err
56+ }
57+ h.logger.Info("Bootstrap complete via repo clone")
58+ result.Success = true
59+ result.Method = "clone"
60+ return result, nil
61+ } else {
62+ h.logger.Warn("Repo clone failed, falling back to downloads", "error", err)
63+ }
64+
65+ // Fallback: download files
66+ if err := h.downloadFiles(ctx); err != nil {
67+ result.ErrorDetail = fmt.Sprintf("Failed to download files: %v", err)
68+ return result, err
69+ }
70+
71+ h.logger.Info("Bootstrap complete via downloads")
72+ result.Success = true
73+ result.Method = "download"
74+ return result, nil
75+}
76+
77+// cloneRepo clones the archup repository
78+func (h *BootstrapHandler) cloneRepo(ctx context.Context) error {
79+ repoDir := config.DefaultInstallRepoDir
80+
81+ // Check if already cloned
82+ if _, err := h.fs.Stat(filepath.Join(repoDir, ".git")); err == nil {
83+ h.logger.Info("Repository already cloned")
84+ return nil
85+ }
86+
87+ h.logger.Info("Cloning repository", "url", h.repoURL)
88+
89+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", h.repoURL, repoDir)
90+ if output, err := cmd.CombinedOutput(); err != nil {
91+ return fmt.Errorf("git clone failed: %w (output: %s)", err, string(output))
92+ }
93+
94+ return nil
95+}
96+
97+// copyRepoFiles copies install files from cloned repo to install directory
98+func (h *BootstrapHandler) copyRepoFiles() error {
99+ repoDir := config.DefaultInstallRepoDir
100+ installDir := config.DefaultInstallDir
101+
102+ // Files to copy from repo
103+ files := []struct {
104+ src string
105+ dest string
106+ }{
107+ {"install/base.packages", "base.packages"},
108+ {"install/extra.packages", "extra.packages"},
109+ {"install/configs/limine.conf.template", "configs/limine.conf.template"},
110+ {"install/configs/chaotic-aur.conf", "configs/chaotic-aur.conf"},
111+ {"install/configs/shell/shell", "configs/shell/shell"},
112+ {"install/configs/shell/starship.toml", "configs/shell/starship.toml"},
113+ {"install/configs/shell/init", "configs/shell/init"},
114+ {"install/configs/shell/aliases", "configs/shell/aliases"},
115+ {"install/configs/shell/envs", "configs/shell/envs"},
116+ {"install/configs/shell/rc", "configs/shell/rc"},
117+ {"install/configs/shell/bashrc", "configs/shell/bashrc"},
118+ }
119+
120+ for _, f := range files {
121+ srcPath := filepath.Join(repoDir, f.src)
122+ destPath := filepath.Join(installDir, f.dest)
123+
124+ // Create destination directory
125+ if err := h.fs.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
126+ return fmt.Errorf("failed to create directory for %s: %w", f.dest, err)
127+ }
128+
129+ // Read and copy file
130+ content, err := h.fs.ReadFile(srcPath)
131+ if err != nil {
132+ h.logger.Warn("File not found in repo", "file", f.src)
133+ continue // Skip missing files
134+ }
135+
136+ if err := h.fs.WriteFile(destPath, content, 0644); err != nil {
137+ return fmt.Errorf("failed to write %s: %w", f.dest, err)
138+ }
139+
140+ h.logger.Info("Copied file", "file", f.dest)
141+ }
142+
143+ return nil
144+}
145+
146+// downloadFiles downloads install files from GitHub
147+func (h *BootstrapHandler) downloadFiles(_ context.Context) error {
148+ files := []struct {
149+ urlPath string
150+ destPath string
151+ }{
152+ {"install/base.packages", "base.packages"},
153+ {"install/extra.packages", "extra.packages"},
154+ {"install/configs/limine.conf.template", "configs/limine.conf.template"},
155+ }
156+
157+ for _, f := range files {
158+ url := fmt.Sprintf("%s/%s", h.rawURL, f.urlPath)
159+ destPath := filepath.Join(config.DefaultInstallDir, f.destPath)
160+
161+ // Create destination directory
162+ if err := h.fs.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
163+ return fmt.Errorf("failed to create directory for %s: %w", f.destPath, err)
164+ }
165+
166+ h.logger.Info("Downloading file", "url", url)
167+
168+ resp, err := h.httpClient.Get(url)
169+ if err != nil {
170+ return fmt.Errorf("failed to download %s: %w", f.urlPath, err)
171+ }
172+ defer resp.Close()
173+
174+ if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil {
175+ return fmt.Errorf("failed to write %s: %w", f.destPath, err)
176+ }
177+ }
178+
179+ return nil
180+}
@@ -0,0 +1,180 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "path/filepath"
8+
9+ "github.com/bnema/archup/internal/application/dto"
10+ "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/domain/ports"
12+)
13+
14+// BootstrapHandler handles downloading/cloning install files
15+type BootstrapHandler struct {
16+ fs ports.FileSystem
17+ httpClient ports.HTTPClient
18+ logger ports.Logger
19+ repoURL string
20+ rawURL string
21+}
22+
23+// NewBootstrapHandler creates a new bootstrap handler
24+func NewBootstrapHandler(fs ports.FileSystem, httpClient ports.HTTPClient, logger ports.Logger, repoURL, rawURL string) *BootstrapHandler {
25+ return &BootstrapHandler{
26+ fs: fs,
27+ httpClient: httpClient,
28+ logger: logger,
29+ repoURL: repoURL,
30+ rawURL: rawURL,
31+ }
32+}
33+
34+// Handle prepares install files by cloning repo or downloading
35+func (h *BootstrapHandler) Handle(ctx context.Context) (*dto.BootstrapResult, error) {
36+ h.logger.Info("Starting bootstrap")
37+
38+ result := &dto.BootstrapResult{
39+ Success: false,
40+ InstallDir: config.DefaultInstallDir,
41+ ErrorDetail: "",
42+ }
43+
44+ // Create install directory
45+ if err := h.fs.MkdirAll(config.DefaultInstallDir, 0755); err != nil {
46+ result.ErrorDetail = fmt.Sprintf("Failed to create install directory: %v", err)
47+ return result, err
48+ }
49+
50+ // Try to clone repo first
51+ if err := h.cloneRepo(ctx); err == nil {
52+ if err := h.copyRepoFiles(); err != nil {
53+ h.logger.Warn("Failed to copy repo files", "error", err)
54+ result.ErrorDetail = fmt.Sprintf("Failed to copy repo files: %v", err)
55+ return result, err
56+ }
57+ h.logger.Info("Bootstrap complete via repo clone")
58+ result.Success = true
59+ result.Method = "clone"
60+ return result, nil
61+ } else {
62+ h.logger.Warn("Repo clone failed, falling back to downloads", "error", err)
63+ }
64+
65+ // Fallback: download files
66+ if err := h.downloadFiles(ctx); err != nil {
67+ result.ErrorDetail = fmt.Sprintf("Failed to download files: %v", err)
68+ return result, err
69+ }
70+
71+ h.logger.Info("Bootstrap complete via downloads")
72+ result.Success = true
73+ result.Method = "download"
74+ return result, nil
75+}
76+
77+// cloneRepo clones the archup repository
78+func (h *BootstrapHandler) cloneRepo(ctx context.Context) error {
79+ repoDir := config.DefaultInstallRepoDir
80+
81+ // Check if already cloned
82+ if _, err := h.fs.Stat(filepath.Join(repoDir, ".git")); err == nil {
83+ h.logger.Info("Repository already cloned")
84+ return nil
85+ }
86+
87+ h.logger.Info("Cloning repository", "url", h.repoURL)
88+
89+ cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", h.repoURL, repoDir)
90+ if output, err := cmd.CombinedOutput(); err != nil {
91+ return fmt.Errorf("git clone failed: %w (output: %s)", err, string(output))
92+ }
93+
94+ return nil
95+}
96+
97+// copyRepoFiles copies install files from cloned repo to install directory
98+func (h *BootstrapHandler) copyRepoFiles() error {
99+ repoDir := config.DefaultInstallRepoDir
100+ installDir := config.DefaultInstallDir
101+
102+ // Files to copy from repo
103+ files := []struct {
104+ src string
105+ dest string
106+ }{
107+ {"install/base.packages", "base.packages"},
108+ {"install/extra.packages", "extra.packages"},
109+ {"install/configs/limine.conf.template", "configs/limine.conf.template"},
110+ {"install/configs/chaotic-aur.conf", "configs/chaotic-aur.conf"},
111+ {"install/configs/shell/shell", "configs/shell/shell"},
112+ {"install/configs/shell/starship.toml", "configs/shell/starship.toml"},
113+ {"install/configs/shell/init", "configs/shell/init"},
114+ {"install/configs/shell/aliases", "configs/shell/aliases"},
115+ {"install/configs/shell/envs", "configs/shell/envs"},
116+ {"install/configs/shell/rc", "configs/shell/rc"},
117+ {"install/configs/shell/bashrc", "configs/shell/bashrc"},
118+ }
119+
120+ for _, f := range files {
121+ srcPath := filepath.Join(repoDir, f.src)
122+ destPath := filepath.Join(installDir, f.dest)
123+
124+ // Create destination directory
125+ if err := h.fs.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
126+ return fmt.Errorf("failed to create directory for %s: %w", f.dest, err)
127+ }
128+
129+ // Read and copy file
130+ content, err := h.fs.ReadFile(srcPath)
131+ if err != nil {
132+ h.logger.Warn("File not found in repo", "file", f.src)
133+ continue // Skip missing files
134+ }
135+
136+ if err := h.fs.WriteFile(destPath, content, 0644); err != nil {
137+ return fmt.Errorf("failed to write %s: %w", f.dest, err)
138+ }
139+
140+ h.logger.Info("Copied file", "file", f.dest)
141+ }
142+
143+ return nil
144+}
145+
146+// downloadFiles downloads install files from GitHub
147+func (h *BootstrapHandler) downloadFiles(_ context.Context) error {
148+ files := []struct {
149+ urlPath string
150+ destPath string
151+ }{
152+ {"install/base.packages", "base.packages"},
153+ {"install/extra.packages", "extra.packages"},
154+ {"install/configs/limine.conf.template", "configs/limine.conf.template"},
155+ }
156+
157+ for _, f := range files {
158+ url := fmt.Sprintf("%s/%s", h.rawURL, f.urlPath)
159+ destPath := filepath.Join(config.DefaultInstallDir, f.destPath)
160+
161+ // Create destination directory
162+ if err := h.fs.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
163+ return fmt.Errorf("failed to create directory for %s: %w", f.destPath, err)
164+ }
165+
166+ h.logger.Info("Downloading file", "url", url)
167+
168+ resp, err := h.httpClient.Get(url)
169+ if err != nil {
170+ return fmt.Errorf("failed to download %s: %w", f.urlPath, err)
171+ }
172+ defer resp.Close()
173+
174+ if err := h.fs.WriteFile(destPath, resp.Body(), 0644); err != nil {
175+ return fmt.Errorf("failed to write %s: %w", f.destPath, err)
176+ }
177+ }
178+
179+ return nil
180+}
A internal/application/handlers/gpu_handler.go +92 -0
@@ -0,0 +1,92 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/archup/internal/domain/ports"
8+ "github.com/bnema/archup/internal/domain/system"
9+)
10+
11+// GPUHandler handles GPU detection.
12+type GPUHandler struct {
13+ cmdExec ports.CommandExecutor
14+ logger ports.Logger
15+}
16+
17+// NewGPUHandler creates a new GPU handler.
18+func NewGPUHandler(cmdExec ports.CommandExecutor, logger ports.Logger) *GPUHandler {
19+ return &GPUHandler{
20+ cmdExec: cmdExec,
21+ logger: logger,
22+ }
23+}
24+
25+// Detect detects the system GPU and returns recommended configuration.
26+func (h *GPUHandler) Detect(ctx context.Context) (*system.GPU, error) {
27+ output, err := h.cmdExec.Execute(ctx, "lspci")
28+ if err != nil {
29+ h.logger.Warn("Failed to run lspci for GPU detection", "error", err)
30+ return system.NewGPU(system.GPUVendorUnknown, "", nil, nil), err
31+ }
32+
33+ line := firstGPULine(string(output))
34+ if line == "" {
35+ h.logger.Warn("No GPU detected in lspci output")
36+ return system.NewGPU(system.GPUVendorUnknown, "", nil, nil), nil
37+ }
38+
39+ vendor := detectGPUVendor(line)
40+ model := extractGPUModel(line)
41+ drivers := recommendedDrivers(vendor)
42+
43+ h.logger.Info("Detected GPU", "vendor", vendor, "model", model)
44+ return system.NewGPU(vendor, model, drivers, nil), nil
45+}
46+
47+func firstGPULine(output string) string {
48+ lines := strings.Split(output, "\n")
49+ for _, line := range lines {
50+ if strings.Contains(line, "VGA compatible controller") ||
51+ strings.Contains(line, "3D controller") ||
52+ strings.Contains(line, "Display controller") {
53+ return line
54+ }
55+ }
56+ return ""
57+}
58+
59+func detectGPUVendor(line string) system.GPUVendor {
60+ lower := strings.ToLower(line)
61+ switch {
62+ case strings.Contains(lower, "nvidia"):
63+ return system.GPUVendorNVIDIA
64+ case strings.Contains(lower, "advanced micro devices") || strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
65+ return system.GPUVendorAMD
66+ case strings.Contains(lower, "intel"):
67+ return system.GPUVendorIntel
68+ default:
69+ return system.GPUVendorUnknown
70+ }
71+}
72+
73+func extractGPUModel(line string) string {
74+ parts := strings.SplitN(line, ": ", 2)
75+ if len(parts) < 2 {
76+ return "Unknown"
77+ }
78+ return strings.TrimSpace(parts[1])
79+}
80+
81+func recommendedDrivers(vendor system.GPUVendor) []string {
82+ switch vendor {
83+ case system.GPUVendorAMD:
84+ return []string{"mesa", "vulkan-radeon", "libva-mesa-driver", "mesa-vdpau"}
85+ case system.GPUVendorIntel:
86+ return []string{"mesa", "vulkan-intel", "intel-media-driver"}
87+ case system.GPUVendorNVIDIA:
88+ return []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"}
89+ default:
90+ return []string{}
91+ }
92+}
@@ -0,0 +1,92 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/archup/internal/domain/ports"
8+ "github.com/bnema/archup/internal/domain/system"
9+)
10+
11+// GPUHandler handles GPU detection.
12+type GPUHandler struct {
13+ cmdExec ports.CommandExecutor
14+ logger ports.Logger
15+}
16+
17+// NewGPUHandler creates a new GPU handler.
18+func NewGPUHandler(cmdExec ports.CommandExecutor, logger ports.Logger) *GPUHandler {
19+ return &GPUHandler{
20+ cmdExec: cmdExec,
21+ logger: logger,
22+ }
23+}
24+
25+// Detect detects the system GPU and returns recommended configuration.
26+func (h *GPUHandler) Detect(ctx context.Context) (*system.GPU, error) {
27+ output, err := h.cmdExec.Execute(ctx, "lspci")
28+ if err != nil {
29+ h.logger.Warn("Failed to run lspci for GPU detection", "error", err)
30+ return system.NewGPU(system.GPUVendorUnknown, "", nil, nil), err
31+ }
32+
33+ line := firstGPULine(string(output))
34+ if line == "" {
35+ h.logger.Warn("No GPU detected in lspci output")
36+ return system.NewGPU(system.GPUVendorUnknown, "", nil, nil), nil
37+ }
38+
39+ vendor := detectGPUVendor(line)
40+ model := extractGPUModel(line)
41+ drivers := recommendedDrivers(vendor)
42+
43+ h.logger.Info("Detected GPU", "vendor", vendor, "model", model)
44+ return system.NewGPU(vendor, model, drivers, nil), nil
45+}
46+
47+func firstGPULine(output string) string {
48+ lines := strings.Split(output, "\n")
49+ for _, line := range lines {
50+ if strings.Contains(line, "VGA compatible controller") ||
51+ strings.Contains(line, "3D controller") ||
52+ strings.Contains(line, "Display controller") {
53+ return line
54+ }
55+ }
56+ return ""
57+}
58+
59+func detectGPUVendor(line string) system.GPUVendor {
60+ lower := strings.ToLower(line)
61+ switch {
62+ case strings.Contains(lower, "nvidia"):
63+ return system.GPUVendorNVIDIA
64+ case strings.Contains(lower, "advanced micro devices") || strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
65+ return system.GPUVendorAMD
66+ case strings.Contains(lower, "intel"):
67+ return system.GPUVendorIntel
68+ default:
69+ return system.GPUVendorUnknown
70+ }
71+}
72+
73+func extractGPUModel(line string) string {
74+ parts := strings.SplitN(line, ": ", 2)
75+ if len(parts) < 2 {
76+ return "Unknown"
77+ }
78+ return strings.TrimSpace(parts[1])
79+}
80+
81+func recommendedDrivers(vendor system.GPUVendor) []string {
82+ switch vendor {
83+ case system.GPUVendorAMD:
84+ return []string{"mesa", "vulkan-radeon", "libva-mesa-driver", "mesa-vdpau"}
85+ case system.GPUVendorIntel:
86+ return []string{"mesa", "vulkan-intel", "intel-media-driver"}
87+ case system.GPUVendorNVIDIA:
88+ return []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"}
89+ default:
90+ return []string{}
91+ }
92+}
A internal/defaults/defaults.go +32 -0
@@ -0,0 +1,32 @@
1+package defaults
2+
3+import (
4+ "embed"
5+ "fmt"
6+ "strings"
7+)
8+
9+//go:embed packages/*.packages
10+var packageLists embed.FS
11+
12+// ReadPackageList loads a named package list.
13+func ReadPackageList(name string) ([]string, error) {
14+ path := fmt.Sprintf("packages/%s.packages", name)
15+ data, err := packageLists.ReadFile(path)
16+ if err != nil {
17+ return nil, fmt.Errorf("read package list %s: %w", name, err)
18+ }
19+ return splitPackages(string(data)), nil
20+}
21+
22+func splitPackages(contents string) []string {
23+ packages := []string{}
24+ for _, line := range strings.Split(contents, "\n") {
25+ trimmed := strings.TrimSpace(line)
26+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
27+ continue
28+ }
29+ packages = append(packages, strings.Fields(trimmed)...)
30+ }
31+ return packages
32+}
@@ -0,0 +1,32 @@
1+package defaults
2+
3+import (
4+ "embed"
5+ "fmt"
6+ "strings"
7+)
8+
9+//go:embed packages/*.packages
10+var packageLists embed.FS
11+
12+// ReadPackageList loads a named package list.
13+func ReadPackageList(name string) ([]string, error) {
14+ path := fmt.Sprintf("packages/%s.packages", name)
15+ data, err := packageLists.ReadFile(path)
16+ if err != nil {
17+ return nil, fmt.Errorf("read package list %s: %w", name, err)
18+ }
19+ return splitPackages(string(data)), nil
20+}
21+
22+func splitPackages(contents string) []string {
23+ packages := []string{}
24+ for _, line := range strings.Split(contents, "\n") {
25+ trimmed := strings.TrimSpace(line)
26+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
27+ continue
28+ }
29+ packages = append(packages, strings.Fields(trimmed)...)
30+ }
31+ return packages
32+}
A internal/defaults/packages/compositor-hyprland.packages +1 -0
@@ -0,0 +1,1 @@
1+hyprland xdg-desktop-portal-hyprland
@@ -0,0 +1,1 @@
1+hyprland xdg-desktop-portal-hyprland
A internal/defaults/packages/compositor-niri.packages +1 -0
@@ -0,0 +1,1 @@
1+niri xdg-desktop-portal-gnome
@@ -0,0 +1,1 @@
1+niri xdg-desktop-portal-gnome
A internal/defaults/packages/desktop-core.packages +2 -0
@@ -0,0 +1,2 @@
1+foot waybar fuzzel hypridle hyprlock swww mako nautilus
2+xdg-desktop-portal polkit-kde-agent wl-clipboard grim slurp wlr-randr
@@ -0,0 +1,2 @@
1+foot waybar fuzzel hypridle hyprlock swww mako nautilus
2+xdg-desktop-portal polkit-kde-agent wl-clipboard grim slurp wlr-randr
A internal/defaults/packages/gtk.packages +1 -0
@@ -0,0 +1,1 @@
1+gtk4 libadwaita adw-gtk3
@@ -0,0 +1,1 @@
1+gtk4 libadwaita adw-gtk3
A internal/defaults/packages/sddm.packages +1 -0
@@ -0,0 +1,1 @@
1+sddm qt6-svg qt6-virtualkeyboard
@@ -0,0 +1,1 @@
1+sddm qt6-svg qt6-virtualkeyboard
A internal/defaults/packages/video-accel.packages +4 -0
@@ -0,0 +1,4 @@
1+libva libva-utils nvtop vulkan-icd-loader vulkan-tools
2+gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad
3+gst-plugins-ugly gst-plugin-va gst-libav ffmpeg
4+pipewire pipewire-pulse pipewire-alsa wireplumber
@@ -0,0 +1,4 @@
1+libva libva-utils nvtop vulkan-icd-loader vulkan-tools
2+gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad
3+gst-plugins-ugly gst-plugin-va gst-libav ffmpeg
4+pipewire pipewire-pulse pipewire-alsa wireplumber
A internal/domain/system/gpu.go +81 -0
@@ -0,0 +1,81 @@
1+package system
2+
3+import "strings"
4+
5+// GPUVendor represents the GPU vendor.
6+type GPUVendor string
7+
8+const (
9+ GPUVendorAMD GPUVendor = "amd"
10+ GPUVendorIntel GPUVendor = "intel"
11+ GPUVendorNVIDIA GPUVendor = "nvidia"
12+ GPUVendorUnknown GPUVendor = "unknown"
13+)
14+
15+// String returns human-readable vendor name.
16+func (v GPUVendor) String() string {
17+ switch v {
18+ case GPUVendorAMD:
19+ return "AMD"
20+ case GPUVendorIntel:
21+ return "Intel"
22+ case GPUVendorNVIDIA:
23+ return "NVIDIA"
24+ default:
25+ return "Unknown"
26+ }
27+}
28+
29+// GPU represents a detected GPU and its recommended configuration.
30+type GPU struct {
31+ vendor GPUVendor
32+ model string
33+ drivers []string
34+ envVars map[string]string
35+}
36+
37+// NewGPU creates a new GPU value object.
38+func NewGPU(vendor GPUVendor, model string, drivers []string, envVars map[string]string) *GPU {
39+ if strings.TrimSpace(model) == "" {
40+ model = "Unknown"
41+ }
42+
43+ if drivers == nil {
44+ drivers = []string{}
45+ }
46+
47+ if envVars == nil {
48+ envVars = map[string]string{}
49+ }
50+
51+ return &GPU{
52+ vendor: vendor,
53+ model: model,
54+ drivers: drivers,
55+ envVars: envVars,
56+ }
57+}
58+
59+// Vendor returns the GPU vendor.
60+func (g *GPU) Vendor() GPUVendor {
61+ return g.vendor
62+}
63+
64+// Model returns the GPU model name.
65+func (g *GPU) Model() string {
66+ return g.model
67+}
68+
69+// Drivers returns recommended GPU driver packages.
70+func (g *GPU) Drivers() []string {
71+ return append([]string{}, g.drivers...)
72+}
73+
74+// EnvVars returns recommended environment variables for compositors.
75+func (g *GPU) EnvVars() map[string]string {
76+ copyVars := make(map[string]string, len(g.envVars))
77+ for key, value := range g.envVars {
78+ copyVars[key] = value
79+ }
80+ return copyVars
81+}
@@ -0,0 +1,81 @@
1+package system
2+
3+import "strings"
4+
5+// GPUVendor represents the GPU vendor.
6+type GPUVendor string
7+
8+const (
9+ GPUVendorAMD GPUVendor = "amd"
10+ GPUVendorIntel GPUVendor = "intel"
11+ GPUVendorNVIDIA GPUVendor = "nvidia"
12+ GPUVendorUnknown GPUVendor = "unknown"
13+)
14+
15+// String returns human-readable vendor name.
16+func (v GPUVendor) String() string {
17+ switch v {
18+ case GPUVendorAMD:
19+ return "AMD"
20+ case GPUVendorIntel:
21+ return "Intel"
22+ case GPUVendorNVIDIA:
23+ return "NVIDIA"
24+ default:
25+ return "Unknown"
26+ }
27+}
28+
29+// GPU represents a detected GPU and its recommended configuration.
30+type GPU struct {
31+ vendor GPUVendor
32+ model string
33+ drivers []string
34+ envVars map[string]string
35+}
36+
37+// NewGPU creates a new GPU value object.
38+func NewGPU(vendor GPUVendor, model string, drivers []string, envVars map[string]string) *GPU {
39+ if strings.TrimSpace(model) == "" {
40+ model = "Unknown"
41+ }
42+
43+ if drivers == nil {
44+ drivers = []string{}
45+ }
46+
47+ if envVars == nil {
48+ envVars = map[string]string{}
49+ }
50+
51+ return &GPU{
52+ vendor: vendor,
53+ model: model,
54+ drivers: drivers,
55+ envVars: envVars,
56+ }
57+}
58+
59+// Vendor returns the GPU vendor.
60+func (g *GPU) Vendor() GPUVendor {
61+ return g.vendor
62+}
63+
64+// Model returns the GPU model name.
65+func (g *GPU) Model() string {
66+ return g.model
67+}
68+
69+// Drivers returns recommended GPU driver packages.
70+func (g *GPU) Drivers() []string {
71+ return append([]string{}, g.drivers...)
72+}
73+
74+// EnvVars returns recommended environment variables for compositors.
75+func (g *GPU) EnvVars() map[string]string {
76+ copyVars := make(map[string]string, len(g.envVars))
77+ for key, value := range g.envVars {
78+ copyVars[key] = value
79+ }
80+ return copyVars
81+}
A internal/interfaces/tui/models/disk.go +109 -0
@@ -0,0 +1,109 @@
1+package models
2+
3+import legacysystem "github.com/bnema/archup/internal/system"
4+
5+// DiskOption represents a selectable disk option.
6+type DiskOption struct {
7+ Label string
8+ Path string
9+ Size string
10+ Model string
11+}
12+
13+// DiskModelImpl holds disk selection state.
14+type DiskModelImpl struct {
15+ disks []legacysystem.Disk
16+ options []DiskOption
17+ selected int
18+ err error
19+}
20+
21+// NewDiskModel creates a new disk selection model.
22+func NewDiskModel() *DiskModelImpl {
23+ return &DiskModelImpl{
24+ options: []DiskOption{},
25+ selected: 0,
26+ }
27+}
28+
29+// SetDisks populates the disk options from detected disks.
30+func (dm *DiskModelImpl) SetDisks(disks []legacysystem.Disk) {
31+ dm.disks = disks
32+ dm.options = make([]DiskOption, 0, len(disks))
33+
34+ for _, disk := range disks {
35+ label := disk.Path + " (" + disk.Size + ")"
36+ if disk.Model != "" {
37+ label += " " + disk.Model
38+ }
39+ if disk.Serial != "" {
40+ label += " [" + disk.Serial + "]"
41+ }
42+ if disk.Vendor != "" && disk.Model == "" {
43+ label += " " + disk.Vendor
44+ }
45+
46+ dm.options = append(dm.options, DiskOption{
47+ Label: label,
48+ Path: disk.Path,
49+ Size: disk.Size,
50+ Model: disk.Model,
51+ })
52+ }
53+}
54+
55+// SetError sets an error that occurred during disk detection.
56+func (dm *DiskModelImpl) SetError(err error) {
57+ dm.err = err
58+}
59+
60+// Error returns any error from disk detection.
61+func (dm *DiskModelImpl) Error() error {
62+ return dm.err
63+}
64+
65+// Options returns the selectable disk options.
66+func (dm *DiskModelImpl) Options() []DiskOption {
67+ return dm.options
68+}
69+
70+// SelectedIndex returns the current selection index.
71+func (dm *DiskModelImpl) SelectedIndex() int {
72+ return dm.selected
73+}
74+
75+// SelectedOption returns the currently selected disk option.
76+func (dm *DiskModelImpl) SelectedOption() DiskOption {
77+ if len(dm.options) == 0 {
78+ return DiskOption{Label: "No disks found", Path: ""}
79+ }
80+ if dm.selected < 0 || dm.selected >= len(dm.options) {
81+ return dm.options[0]
82+ }
83+ return dm.options[dm.selected]
84+}
85+
86+// MoveUp moves selection up.
87+func (dm *DiskModelImpl) MoveUp() {
88+ if len(dm.options) == 0 {
89+ return
90+ }
91+ if dm.selected == 0 {
92+ dm.selected = len(dm.options) - 1
93+ return
94+ }
95+ dm.selected--
96+}
97+
98+// MoveDown moves selection down.
99+func (dm *DiskModelImpl) MoveDown() {
100+ if len(dm.options) == 0 {
101+ return
102+ }
103+ dm.selected = (dm.selected + 1) % len(dm.options)
104+}
105+
106+// HasDisks returns true if any disks were detected.
107+func (dm *DiskModelImpl) HasDisks() bool {
108+ return len(dm.options) > 0
109+}
@@ -0,0 +1,109 @@
1+package models
2+
3+import legacysystem "github.com/bnema/archup/internal/system"
4+
5+// DiskOption represents a selectable disk option.
6+type DiskOption struct {
7+ Label string
8+ Path string
9+ Size string
10+ Model string
11+}
12+
13+// DiskModelImpl holds disk selection state.
14+type DiskModelImpl struct {
15+ disks []legacysystem.Disk
16+ options []DiskOption
17+ selected int
18+ err error
19+}
20+
21+// NewDiskModel creates a new disk selection model.
22+func NewDiskModel() *DiskModelImpl {
23+ return &DiskModelImpl{
24+ options: []DiskOption{},
25+ selected: 0,
26+ }
27+}
28+
29+// SetDisks populates the disk options from detected disks.
30+func (dm *DiskModelImpl) SetDisks(disks []legacysystem.Disk) {
31+ dm.disks = disks
32+ dm.options = make([]DiskOption, 0, len(disks))
33+
34+ for _, disk := range disks {
35+ label := disk.Path + " (" + disk.Size + ")"
36+ if disk.Model != "" {
37+ label += " " + disk.Model
38+ }
39+ if disk.Serial != "" {
40+ label += " [" + disk.Serial + "]"
41+ }
42+ if disk.Vendor != "" && disk.Model == "" {
43+ label += " " + disk.Vendor
44+ }
45+
46+ dm.options = append(dm.options, DiskOption{
47+ Label: label,
48+ Path: disk.Path,
49+ Size: disk.Size,
50+ Model: disk.Model,
51+ })
52+ }
53+}
54+
55+// SetError sets an error that occurred during disk detection.
56+func (dm *DiskModelImpl) SetError(err error) {
57+ dm.err = err
58+}
59+
60+// Error returns any error from disk detection.
61+func (dm *DiskModelImpl) Error() error {
62+ return dm.err
63+}
64+
65+// Options returns the selectable disk options.
66+func (dm *DiskModelImpl) Options() []DiskOption {
67+ return dm.options
68+}
69+
70+// SelectedIndex returns the current selection index.
71+func (dm *DiskModelImpl) SelectedIndex() int {
72+ return dm.selected
73+}
74+
75+// SelectedOption returns the currently selected disk option.
76+func (dm *DiskModelImpl) SelectedOption() DiskOption {
77+ if len(dm.options) == 0 {
78+ return DiskOption{Label: "No disks found", Path: ""}
79+ }
80+ if dm.selected < 0 || dm.selected >= len(dm.options) {
81+ return dm.options[0]
82+ }
83+ return dm.options[dm.selected]
84+}
85+
86+// MoveUp moves selection up.
87+func (dm *DiskModelImpl) MoveUp() {
88+ if len(dm.options) == 0 {
89+ return
90+ }
91+ if dm.selected == 0 {
92+ dm.selected = len(dm.options) - 1
93+ return
94+ }
95+ dm.selected--
96+}
97+
98+// MoveDown moves selection down.
99+func (dm *DiskModelImpl) MoveDown() {
100+ if len(dm.options) == 0 {
101+ return
102+ }
103+ dm.selected = (dm.selected + 1) % len(dm.options)
104+}
105+
106+// HasDisks returns true if any disks were detected.
107+func (dm *DiskModelImpl) HasDisks() bool {
108+ return len(dm.options) > 0
109+}
A internal/interfaces/tui/models/gpu.go +99 -0
@@ -0,0 +1,99 @@
1+package models
2+
3+import "github.com/bnema/archup/internal/domain/system"
4+
5+// GPUOption represents a selectable GPU driver option.
6+type GPUOption struct {
7+ Label string
8+ Vendor system.GPUVendor
9+ Drivers []string
10+}
11+
12+// GPUModelImpl holds GPU selection state.
13+type GPUModelImpl struct {
14+ detected *system.GPU
15+ options []GPUOption
16+ selected int
17+}
18+
19+// NewGPUModel creates a new GPU selection model.
20+func NewGPUModel() *GPUModelImpl {
21+ options := []GPUOption{
22+ {
23+ Label: "AMD",
24+ Vendor: system.GPUVendorAMD,
25+ Drivers: []string{"mesa", "vulkan-radeon", "libva-mesa-driver", "mesa-vdpau"},
26+ },
27+ {
28+ Label: "Intel",
29+ Vendor: system.GPUVendorIntel,
30+ Drivers: []string{"mesa", "vulkan-intel", "intel-media-driver"},
31+ },
32+ {
33+ Label: "NVIDIA (open)",
34+ Vendor: system.GPUVendorNVIDIA,
35+ Drivers: []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"},
36+ },
37+ {
38+ Label: "Skip GPU drivers",
39+ Vendor: system.GPUVendorUnknown,
40+ Drivers: []string{},
41+ },
42+ }
43+
44+ return &GPUModelImpl{
45+ options: options,
46+ selected: 0,
47+ }
48+}
49+
50+// SetDetectedGPU sets the detected GPU and aligns selection.
51+func (gm *GPUModelImpl) SetDetectedGPU(gpu *system.GPU) {
52+ gm.detected = gpu
53+ if gpu == nil {
54+ return
55+ }
56+ for i, option := range gm.options {
57+ if option.Vendor == gpu.Vendor() {
58+ gm.selected = i
59+ return
60+ }
61+ }
62+}
63+
64+// DetectedGPU returns the detected GPU.
65+func (gm *GPUModelImpl) DetectedGPU() *system.GPU {
66+ return gm.detected
67+}
68+
69+// Options returns the selectable options.
70+func (gm *GPUModelImpl) Options() []GPUOption {
71+ return gm.options
72+}
73+
74+// SelectedIndex returns the current selection index.
75+func (gm *GPUModelImpl) SelectedIndex() int {
76+ return gm.selected
77+}
78+
79+// SelectedOption returns the currently selected option.
80+func (gm *GPUModelImpl) SelectedOption() GPUOption {
81+ if gm.selected < 0 || gm.selected >= len(gm.options) {
82+ return gm.options[0]
83+ }
84+ return gm.options[gm.selected]
85+}
86+
87+// MoveUp moves selection up.
88+func (gm *GPUModelImpl) MoveUp() {
89+ if gm.selected == 0 {
90+ gm.selected = len(gm.options) - 1
91+ return
92+ }
93+ gm.selected--
94+}
95+
96+// MoveDown moves selection down.
97+func (gm *GPUModelImpl) MoveDown() {
98+ gm.selected = (gm.selected + 1) % len(gm.options)
99+}
@@ -0,0 +1,99 @@
1+package models
2+
3+import "github.com/bnema/archup/internal/domain/system"
4+
5+// GPUOption represents a selectable GPU driver option.
6+type GPUOption struct {
7+ Label string
8+ Vendor system.GPUVendor
9+ Drivers []string
10+}
11+
12+// GPUModelImpl holds GPU selection state.
13+type GPUModelImpl struct {
14+ detected *system.GPU
15+ options []GPUOption
16+ selected int
17+}
18+
19+// NewGPUModel creates a new GPU selection model.
20+func NewGPUModel() *GPUModelImpl {
21+ options := []GPUOption{
22+ {
23+ Label: "AMD",
24+ Vendor: system.GPUVendorAMD,
25+ Drivers: []string{"mesa", "vulkan-radeon", "libva-mesa-driver", "mesa-vdpau"},
26+ },
27+ {
28+ Label: "Intel",
29+ Vendor: system.GPUVendorIntel,
30+ Drivers: []string{"mesa", "vulkan-intel", "intel-media-driver"},
31+ },
32+ {
33+ Label: "NVIDIA (open)",
34+ Vendor: system.GPUVendorNVIDIA,
35+ Drivers: []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"},
36+ },
37+ {
38+ Label: "Skip GPU drivers",
39+ Vendor: system.GPUVendorUnknown,
40+ Drivers: []string{},
41+ },
42+ }
43+
44+ return &GPUModelImpl{
45+ options: options,
46+ selected: 0,
47+ }
48+}
49+
50+// SetDetectedGPU sets the detected GPU and aligns selection.
51+func (gm *GPUModelImpl) SetDetectedGPU(gpu *system.GPU) {
52+ gm.detected = gpu
53+ if gpu == nil {
54+ return
55+ }
56+ for i, option := range gm.options {
57+ if option.Vendor == gpu.Vendor() {
58+ gm.selected = i
59+ return
60+ }
61+ }
62+}
63+
64+// DetectedGPU returns the detected GPU.
65+func (gm *GPUModelImpl) DetectedGPU() *system.GPU {
66+ return gm.detected
67+}
68+
69+// Options returns the selectable options.
70+func (gm *GPUModelImpl) Options() []GPUOption {
71+ return gm.options
72+}
73+
74+// SelectedIndex returns the current selection index.
75+func (gm *GPUModelImpl) SelectedIndex() int {
76+ return gm.selected
77+}
78+
79+// SelectedOption returns the currently selected option.
80+func (gm *GPUModelImpl) SelectedOption() GPUOption {
81+ if gm.selected < 0 || gm.selected >= len(gm.options) {
82+ return gm.options[0]
83+ }
84+ return gm.options[gm.selected]
85+}
86+
87+// MoveUp moves selection up.
88+func (gm *GPUModelImpl) MoveUp() {
89+ if gm.selected == 0 {
90+ gm.selected = len(gm.options) - 1
91+ return
92+ }
93+ gm.selected--
94+}
95+
96+// MoveDown moves selection down.
97+func (gm *GPUModelImpl) MoveDown() {
98+ gm.selected = (gm.selected + 1) % len(gm.options)
99+}
A internal/interfaces/tui/views/disk.go +63 -0
@@ -0,0 +1,63 @@
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+// RenderDiskSelection renders the disk selection screen.
11+func RenderDiskSelection(dm *models.DiskModelImpl) string {
12+ var b strings.Builder
13+
14+ titleStyle := lipgloss.NewStyle().
15+ Bold(true).
16+ Foreground(lipgloss.Color("12"))
17+
18+ selectedStyle := lipgloss.NewStyle().
19+ Bold(true).
20+ Foreground(lipgloss.Color("10"))
21+
22+ normalStyle := lipgloss.NewStyle().
23+ Foreground(lipgloss.Color("7"))
24+
25+ dimStyle := lipgloss.NewStyle().
26+ Foreground(lipgloss.Color("8"))
27+
28+ warningStyle := lipgloss.NewStyle().
29+ Bold(true).
30+ Foreground(lipgloss.Color("11"))
31+
32+ b.WriteString("\n")
33+ b.WriteString(titleStyle.Render("Select Target Disk"))
34+ b.WriteString("\n\n")
35+
36+ if dm.Error() != nil {
37+ b.WriteString(lipgloss.NewStyle().
38+ Foreground(lipgloss.Color("1")).
39+ Render("Error detecting disks: " + dm.Error().Error()))
40+ b.WriteString("\n")
41+ }
42+
43+ if !dm.HasDisks() {
44+ b.WriteString(dimStyle.Render("No disks detected"))
45+ b.WriteString("\n")
46+ } else {
47+ for i, option := range dm.Options() {
48+ if i == dm.SelectedIndex() {
49+ b.WriteString(selectedStyle.Render("> " + option.Label))
50+ } else {
51+ b.WriteString(normalStyle.Render(" " + option.Label))
52+ }
53+ b.WriteString("\n")
54+ }
55+ }
56+
57+ b.WriteString("\n")
58+ b.WriteString(warningStyle.Render("⚠ WARNING: Selected disk will be completely erased!"))
59+ b.WriteString("\n\n")
60+ b.WriteString(dimStyle.Render("↑↓ Navigate • Enter Select • Ctrl+C Quit"))
61+
62+ return b.String()
63+}
@@ -0,0 +1,63 @@
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+// RenderDiskSelection renders the disk selection screen.
11+func RenderDiskSelection(dm *models.DiskModelImpl) string {
12+ var b strings.Builder
13+
14+ titleStyle := lipgloss.NewStyle().
15+ Bold(true).
16+ Foreground(lipgloss.Color("12"))
17+
18+ selectedStyle := lipgloss.NewStyle().
19+ Bold(true).
20+ Foreground(lipgloss.Color("10"))
21+
22+ normalStyle := lipgloss.NewStyle().
23+ Foreground(lipgloss.Color("7"))
24+
25+ dimStyle := lipgloss.NewStyle().
26+ Foreground(lipgloss.Color("8"))
27+
28+ warningStyle := lipgloss.NewStyle().
29+ Bold(true).
30+ Foreground(lipgloss.Color("11"))
31+
32+ b.WriteString("\n")
33+ b.WriteString(titleStyle.Render("Select Target Disk"))
34+ b.WriteString("\n\n")
35+
36+ if dm.Error() != nil {
37+ b.WriteString(lipgloss.NewStyle().
38+ Foreground(lipgloss.Color("1")).
39+ Render("Error detecting disks: " + dm.Error().Error()))
40+ b.WriteString("\n")
41+ }
42+
43+ if !dm.HasDisks() {
44+ b.WriteString(dimStyle.Render("No disks detected"))
45+ b.WriteString("\n")
46+ } else {
47+ for i, option := range dm.Options() {
48+ if i == dm.SelectedIndex() {
49+ b.WriteString(selectedStyle.Render("> " + option.Label))
50+ } else {
51+ b.WriteString(normalStyle.Render(" " + option.Label))
52+ }
53+ b.WriteString("\n")
54+ }
55+ }
56+
57+ b.WriteString("\n")
58+ b.WriteString(warningStyle.Render("⚠ WARNING: Selected disk will be completely erased!"))
59+ b.WriteString("\n\n")
60+ b.WriteString(dimStyle.Render("↑↓ Navigate • Enter Select • Ctrl+C Quit"))
61+
62+ return b.String()
63+}
A internal/interfaces/tui/views/gpu.go +49 -0
@@ -0,0 +1,49 @@
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+// RenderGPUSelection renders the GPU selection screen.
11+func RenderGPUSelection(gm *models.GPUModelImpl) 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+
18+ b.WriteString("\n")
19+ b.WriteString(title.Render("GPU Driver Selection"))
20+ b.WriteString("\n\n")
21+
22+ if detected := gm.DetectedGPU(); detected != nil {
23+ b.WriteString(info.Render("Detected: "))
24+ b.WriteString(detected.Vendor().String())
25+ b.WriteString(" - ")
26+ b.WriteString(detected.Model())
27+ b.WriteString("\n\n")
28+ }
29+
30+ for i, option := range gm.Options() {
31+ prefix := " "
32+ style := lipgloss.NewStyle()
33+ if i == gm.SelectedIndex() {
34+ prefix = "› "
35+ style = active
36+ }
37+ b.WriteString(style.Render(prefix + option.Label))
38+ if len(option.Drivers) > 0 {
39+ b.WriteString("\n")
40+ b.WriteString(info.Render(" " + strings.Join(option.Drivers, ", ")))
41+ }
42+ b.WriteString("\n")
43+ }
44+
45+ b.WriteString("\n")
46+ b.WriteString(info.Render("↑↓ Move • Enter Confirm • Ctrl+C Quit"))
47+
48+ return b.String()
49+}
@@ -0,0 +1,49 @@
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+// RenderGPUSelection renders the GPU selection screen.
11+func RenderGPUSelection(gm *models.GPUModelImpl) 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+
18+ b.WriteString("\n")
19+ b.WriteString(title.Render("GPU Driver Selection"))
20+ b.WriteString("\n\n")
21+
22+ if detected := gm.DetectedGPU(); detected != nil {
23+ b.WriteString(info.Render("Detected: "))
24+ b.WriteString(detected.Vendor().String())
25+ b.WriteString(" - ")
26+ b.WriteString(detected.Model())
27+ b.WriteString("\n\n")
28+ }
29+
30+ for i, option := range gm.Options() {
31+ prefix := " "
32+ style := lipgloss.NewStyle()
33+ if i == gm.SelectedIndex() {
34+ prefix = "› "
35+ style = active
36+ }
37+ b.WriteString(style.Render(prefix + option.Label))
38+ if len(option.Drivers) > 0 {
39+ b.WriteString("\n")
40+ b.WriteString(info.Render(" " + strings.Join(option.Drivers, ", ")))
41+ }
42+ b.WriteString("\n")
43+ }
44+
45+ b.WriteString("\n")
46+ b.WriteString(info.Render("↑↓ Move • Enter Confirm • Ctrl+C Quit"))
47+
48+ return b.String()
49+}
A internal/wizard/application/services/wizard_service.go +814 -0
@@ -0,0 +1,814 @@
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+}
@@ -0,0 +1,814 @@
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+}
A internal/wizard/domain/compositor.go +9 -0
@@ -0,0 +1,9 @@
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+)
@@ -0,0 +1,9 @@
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+)
A internal/wizard/domain/desktop.go +21 -0
@@ -0,0 +1,21 @@
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+}
@@ -0,0 +1,21 @@
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+}
A internal/wizard/domain/packages.go +7 -0
@@ -0,0 +1,7 @@
1+package domain
2+
3+// PackageSet defines a named list of packages.
4+type PackageSet struct {
5+ Name string
6+ Packages []string
7+}
@@ -0,0 +1,7 @@
1+package domain
2+
3+// PackageSet defines a named list of packages.
4+type PackageSet struct {
5+ Name string
6+ Packages []string
7+}
A internal/wizard/interfaces/tui/views/apply_config.go +23 -0
@@ -0,0 +1,23 @@
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+}
@@ -0,0 +1,23 @@
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+}
A internal/wizard/interfaces/tui/views/complete.go +34 -0
@@ -0,0 +1,34 @@
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+}
@@ -0,0 +1,34 @@
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+}
A internal/wizard/interfaces/tui/views/compositor.go +39 -0
@@ -0,0 +1,39 @@
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+}
@@ -0,0 +1,39 @@
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+}
A internal/wizard/interfaces/tui/views/confirm.go +42 -0
@@ -0,0 +1,42 @@
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+}
@@ -0,0 +1,42 @@
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+}
A internal/wizard/interfaces/tui/views/install.go +23 -0
@@ -0,0 +1,23 @@
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+}
@@ -0,0 +1,23 @@
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+}
A internal/wizard/interfaces/tui/views/monitors.go +80 -0
@@ -0,0 +1,80 @@
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+}
@@ -0,0 +1,80 @@
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+}
A internal/wizard/interfaces/tui/views/optional.go +29 -0
@@ -0,0 +1,29 @@
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+}
@@ -0,0 +1,29 @@
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+}
A internal/wizard/interfaces/tui/views/sddm.go +34 -0
@@ -0,0 +1,34 @@
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+}
@@ -0,0 +1,34 @@
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+}
A internal/wizard/interfaces/tui/views/welcome.go +25 -0
@@ -0,0 +1,25 @@
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+}
@@ -0,0 +1,25 @@
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+}
A internal/wizard/interfaces/tui/wizard_app.go +484 -0
@@ -0,0 +1,484 @@
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+}
@@ -0,0 +1,484 @@
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