diff --git a/core/cmd/dankinstall/main.go b/core/cmd/dankinstall/main.go index ab2020b95..e8fed369a 100644 --- a/core/cmd/dankinstall/main.go +++ b/core/cmd/dankinstall/main.go @@ -14,7 +14,6 @@ import ( var Version = "dev" -// Flag variables bound via pflag var ( compositor string term string @@ -81,7 +80,6 @@ func runDankinstall(cmd *cobra.Command, args []string) error { headlessMode := compositor != "" || term != "" if !headlessMode { - // Reject headless-only flags when running in TUI mode. headlessOnly := []string{ "privesc", "git", @@ -114,7 +112,6 @@ func runDankinstall(cmd *cobra.Command, args []string) error { } func runHeadless() error { - // Validate required flags if compositor == "" { return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)") } @@ -141,7 +138,6 @@ func runHeadless() error { runner := headless.NewRunner(cfg) - // Set up file logging fileLogger, err := log.NewFileLogger() if err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err) @@ -156,10 +152,6 @@ func runHeadless() error { } }() } else { - // Drain the log channel to prevent blocking sends from deadlocking - // downstream components (distros, config deployer) that write to it. - // Use an explicit stop signal because this code does not own the - // runner log channel and cannot assume it will be closed. defer drainLogChan(runner.GetLogChan())() } @@ -199,10 +191,6 @@ func runTUI() error { if fileLogger != nil { fileLogger.StartListening(model.GetLogChan()) } else { - // Drain the log channel to prevent blocking sends from deadlocking - // downstream components (distros, config deployer) that write to it. - // Use an explicit stop signal because this code does not own the - // model log channel and cannot assume it will be closed. defer drainLogChan(model.GetLogChan())() } @@ -220,10 +208,9 @@ func runTUI() error { return nil } -// drainLogChan starts a goroutine that discards all messages from logCh, -// preventing blocking sends from deadlocking downstream components. It returns -// a cleanup function that signals the goroutine to stop and waits for it to -// exit. Callers should defer the returned function. +// drainLogChan discards messages from logCh so blocking sends in downstream +// components cannot deadlock when no file logger is attached; the returned +// cleanup stops the drain without assuming the channel will ever close. func drainLogChan(logCh <-chan string) func() { drainStop := make(chan struct{}) drainDone := make(chan struct{}) diff --git a/core/go.mod b/core/go.mod index 996387d3d..9b7f86613 100644 --- a/core/go.mod +++ b/core/go.mod @@ -83,7 +83,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/core/go.sum b/core/go.sum index ec7154ba4..19b2a1901 100644 --- a/core/go.sum +++ b/core/go.sum @@ -32,8 +32,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= diff --git a/core/internal/tui/app.go b/core/internal/tui/app.go index 61874b9a2..5d8a65d7e 100644 --- a/core/internal/tui/app.go +++ b/core/internal/tui/app.go @@ -36,6 +36,7 @@ type Model struct { selectedTerminal int selectedDep int selectedConfig int + selectedAuthMethod int reinstallItems map[string]bool disabledItems map[string]bool replaceConfigs map[string]bool @@ -43,17 +44,18 @@ type Model struct { sudoPassword string existingConfigs []ExistingConfigInfo fingerprintFailed bool + authValidating bool + authFailed bool availablePrivesc []privesc.Tool selectedPrivesc int } func NewModel(version string, logFilePath string) Model { + styles := NewStyles(TerminalTheme()) + s := spinner.New() s.Spinner = spinner.Dot - - theme := TerminalTheme() - styles := NewStyles(theme) s.Style = styles.SpinnerStyle pi := textinput.New() @@ -62,35 +64,22 @@ func NewModel(version string, logFilePath string) Model { pi.EchoCharacter = '•' pi.Focus() - logChan := make(chan string, 1000) - packageProgressChan := make(chan packageInstallProgressMsg, 100) - return Model{ - version: version, - state: StateWelcome, - spinner: s, - passwordInput: pi, - isLoading: true, - styles: styles, - - logMessages: []string{}, - logChan: logChan, + version: version, + state: StateWelcome, + spinner: s, + passwordInput: pi, + isLoading: true, + styles: styles, + logChan: make(chan string, 1000), logFilePath: logFilePath, - packageProgressChan: packageProgressChan, + packageProgressChan: make(chan packageInstallProgressMsg, 100), packageProgress: packageInstallProgressMsg{ - progress: 0.0, - step: "Initializing package installation", - isComplete: false, + step: "Initializing package installation", }, - showDebugLogs: false, - selectedWM: 0, - selectedTerminal: 0, // Default to Ghostty - selectedDep: 0, - selectedConfig: 0, - reinstallItems: make(map[string]bool), - disabledItems: make(map[string]bool), - replaceConfigs: make(map[string]bool), - installationLogs: []string{}, + reinstallItems: make(map[string]bool), + disabledItems: make(map[string]bool), + replaceConfigs: make(map[string]bool), } } @@ -107,32 +96,29 @@ func (m Model) Init() tea.Cmd { } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { case "ctrl+c": return m, tea.Quit case "ctrl+d": - // Toggle debug logs view (except during password input states) if m.state != StatePasswordPrompt && m.state != StateFingerprintAuth { m.showDebugLogs = !m.showDebugLogs return m, nil } } - } - if tickMsg, ok := msg.(spinner.TickMsg); ok { + case spinner.TickMsg: var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(tickMsg) + m.spinner, cmd = m.spinner.Update(msg) return m, tea.Batch(cmd, m.listenForLogs()) - } - if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { - m.width = sizeMsg.Width - m.height = sizeMsg.Height - } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height - if logMsg, ok := msg.(logMsg); ok { - m.logMessages = append(m.logMessages, logMsg.message) + case logMsg: + m.logMessages = append(m.logMessages, msg.message) return m, m.listenForLogs() } @@ -175,7 +161,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) View() string { - // If debug logs are shown, show that view regardless of state if m.showDebugLogs { return m.viewDebugLogs() } @@ -218,6 +203,26 @@ func (m Model) View() string { } } +func (m Model) startInstallation(password string) (tea.Model, tea.Cmd) { + m.sudoPassword = password + m.passwordInput.SetValue("") + m.authValidating = false + m.authFailed = false + m.packageProgress = packageInstallProgressMsg{} + m.state = StateInstallingPackages + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.installPackages()) +} + +func (m Model) promptForPassword() (tea.Model, tea.Cmd) { + m.authValidating = false + m.authFailed = false + m.passwordInput.SetValue("") + m.passwordInput.Focus() + m.state = StatePasswordPrompt + return m, nil +} + func (m Model) listenForLogs() tea.Cmd { return func() tea.Msg { select { @@ -235,14 +240,17 @@ func (m Model) listenForLogs() tea.Cmd { func (m Model) detectOS() tea.Cmd { return func() tea.Msg { info, err := distros.GetOSInfo() - osInfoMsg := &distros.OSInfo{} - if info != nil { - osInfoMsg.Distribution = info.Distribution - osInfoMsg.Version = info.Version - osInfoMsg.VersionID = info.VersionID - osInfoMsg.PrettyName = info.PrettyName - osInfoMsg.Architecture = info.Architecture + if info == nil { + info = &distros.OSInfo{} } - return osInfoCompleteMsg{info: osInfoMsg, err: err} + return osInfoCompleteMsg{info: info, err: err} } } + +func moveIndex(current, delta, max int) int { + next := current + delta + if next < 0 || next > max { + return current + } + return next +} diff --git a/core/internal/tui/app_test.go b/core/internal/tui/app_test.go new file mode 100644 index 000000000..83f104740 --- /dev/null +++ b/core/internal/tui/app_test.go @@ -0,0 +1,204 @@ +package tui + +import ( + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + tea "github.com/charmbracelet/bubbletea" +) + +func archModel() Model { + m := NewModel("test", "") + m.osInfo = &distros.OSInfo{ + Distribution: distros.DistroInfo{ID: "arch"}, + PrettyName: "Arch Linux", + Architecture: "amd64", + } + m.isLoading = false + return m +} + +func pressKey(t *testing.T, m Model, key tea.KeyMsg) Model { + t.Helper() + updated, _ := m.Update(key) + next, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned %T, want Model", updated) + } + return next +} + +var enterKey = tea.KeyMsg{Type: tea.KeyEnter} +var downKey = tea.KeyMsg{Type: tea.KeyDown} +var escKey = tea.KeyMsg{Type: tea.KeyEsc} + +func TestWelcomeEnterAdvances(t *testing.T) { + m := archModel() + m = pressKey(t, m, enterKey) + if m.state != StateSelectWindowManager { + t.Fatalf("state = %v, want StateSelectWindowManager", m.state) + } +} + +func TestWelcomeEnterBlockedWithoutOSInfo(t *testing.T) { + m := NewModel("test", "") + m = pressKey(t, m, enterKey) + if m.state != StateWelcome { + t.Fatalf("state = %v, want StateWelcome", m.state) + } +} + +func TestWelcomeEnterBlockedOnUnsupportedDistro(t *testing.T) { + m := archModel() + m.osInfo.Distribution.ID = "ubuntu" + m.osInfo.VersionID = "22.04" + m = pressKey(t, m, enterKey) + if m.state != StateWelcome { + t.Fatalf("state = %v, want StateWelcome", m.state) + } +} + +func TestSelectionFlowAndEscape(t *testing.T) { + m := archModel() + m = pressKey(t, m, enterKey) + m = pressKey(t, m, downKey) + if m.selectedWM != 1 { + t.Fatalf("selectedWM = %d, want 1", m.selectedWM) + } + if m.chosenWindowManager() != deps.WindowManagerHyprland { + t.Fatalf("chosenWindowManager = %v, want Hyprland", m.chosenWindowManager()) + } + + m = pressKey(t, m, enterKey) + if m.state != StateSelectTerminal { + t.Fatalf("state = %v, want StateSelectTerminal", m.state) + } + m = pressKey(t, m, downKey) + if m.chosenTerminal() != deps.TerminalKitty { + t.Fatalf("chosenTerminal = %v, want Kitty", m.chosenTerminal()) + } + + m = pressKey(t, m, escKey) + if m.state != StateSelectWindowManager { + t.Fatalf("state = %v, want StateSelectWindowManager after esc", m.state) + } +} + +func TestWindowManagerChoicesOmitHyprlandOnDebian(t *testing.T) { + m := archModel() + m.osInfo.Distribution.ID = "debian" + for _, c := range m.windowManagerChoices() { + if c.wm == deps.WindowManagerHyprland { + t.Fatal("debian choices include Hyprland") + } + } +} + +func TestTerminalChoicesOmitGhosttyOnGentoo(t *testing.T) { + m := archModel() + m.osInfo.Distribution.ID = "gentoo" + choices := m.terminalChoices() + if len(choices) != 2 || choices[0].terminal != deps.TerminalKitty { + t.Fatalf("gentoo terminal choices = %+v, want kitty first without ghostty", choices) + } + if m.chosenTerminal() != deps.TerminalKitty { + t.Fatalf("chosenTerminal = %v, want Kitty default on gentoo", m.chosenTerminal()) + } +} + +func TestMoveIndexClamps(t *testing.T) { + if moveIndex(0, -1, 2) != 0 { + t.Fatal("moveIndex should not go below 0") + } + if moveIndex(2, 1, 2) != 2 { + t.Fatal("moveIndex should not exceed max") + } + if moveIndex(1, 1, 2) != 2 { + t.Fatal("moveIndex should advance within bounds") + } +} + +func TestToggleSelectedDependency(t *testing.T) { + m := archModel() + m.dependencies = []deps.Dependency{ + {Name: dmsDepName, Required: true, Status: deps.StatusMissing}, + {Name: "quickshell", Required: true, Status: deps.StatusInstalled}, + {Name: "matugen", Required: true, Status: deps.StatusMissing}, + } + + m.selectedDep = 0 + m.toggleSelectedDependency() + if m.disabledItems[dmsDepName] || m.reinstallItems[dmsDepName] { + t.Fatal("dms must not be toggleable") + } + + m.selectedDep = 1 + m.toggleSelectedDependency() + if !m.reinstallItems["quickshell"] { + t.Fatal("toggling an installed dep should mark reinstall") + } + + m.selectedDep = 2 + m.toggleSelectedDependency() + if !m.disabledItems["matugen"] { + t.Fatal("toggling a missing dep should mark disabled") + } +} + +func TestDetectedDepsPartitionAndOptOut(t *testing.T) { + m := archModel() + m.state = StateDetectingDeps + updated, _ := m.Update(depsDetectedMsg{deps: []deps.Dependency{ + {Name: "dms-greeter", Required: false}, + {Name: "quickshell", Required: true}, + }}) + m = updated.(Model) + + if m.state != StateDependencyReview { + t.Fatalf("state = %v, want StateDependencyReview", m.state) + } + if m.dependencies[0].Name != "quickshell" { + t.Fatal("required deps should sort before optional") + } + if !m.disabledItems["dms-greeter"] { + t.Fatal("optional deps should default to disabled") + } +} + +func TestPasswordPromptValidation(t *testing.T) { + m := archModel() + m.state = StatePasswordPrompt + + updated, _ := m.Update(passwordValidMsg{valid: false}) + m = updated.(Model) + if !m.authFailed || m.state != StatePasswordPrompt { + t.Fatalf("invalid password: authFailed=%v state=%v", m.authFailed, m.state) + } + + updated, _ = m.Update(passwordValidMsg{password: "hunter2", valid: true}) + m = updated.(Model) + if m.state != StateInstallingPackages || m.sudoPassword != "hunter2" || m.authFailed { + t.Fatalf("valid password: state=%v sudoPassword set=%v", m.state, m.sudoPassword != "") + } +} + +func TestConfigCheckDefaultsToReplace(t *testing.T) { + m := archModel() + m.state = StateConfigConfirmation + updated, _ := m.Update(configCheckResult{configs: []ExistingConfigInfo{ + {ConfigType: "Niri", Path: "/tmp/niri.kdl", Exists: false}, + {ConfigType: "Ghostty", Path: "/tmp/ghostty", Exists: true}, + }}) + m = updated.(Model) + + if m.state != StateConfigConfirmation { + t.Fatalf("state = %v, want StateConfigConfirmation while configs exist", m.state) + } + if !m.replaceConfigs["Ghostty"] { + t.Fatal("existing config should default to replace") + } + if m.selectedConfig != 1 { + t.Fatalf("selectedConfig = %d, want first existing config", m.selectedConfig) + } +} diff --git a/core/internal/tui/banner.go b/core/internal/tui/banner.go index 40695eb4e..710c0b580 100644 --- a/core/internal/tui/banner.go +++ b/core/internal/tui/banner.go @@ -1,7 +1,5 @@ package tui -import "github.com/charmbracelet/lipgloss" - func (m Model) renderBanner() string { logo := ` ██████╗ ███╗ ███╗███████╗ @@ -11,11 +9,5 @@ func (m Model) renderBanner() string { ██████╔╝██║ ╚═╝ ██║███████║ ╚═════╝ ╚═╝ ╚═╝╚══════╝` - theme := TerminalTheme() - style := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Primary)). - Bold(true). - MarginBottom(1) - - return style.Render(logo) + return m.styles.Banner.Render(logo) } diff --git a/core/internal/tui/states.go b/core/internal/tui/states.go index bde1635fc..07411f71f 100644 --- a/core/internal/tui/states.go +++ b/core/internal/tui/states.go @@ -18,6 +18,5 @@ const ( StateConfigConfirmation StateDeployingConfigs StateInstallComplete - StateFinalComplete StateError ) diff --git a/core/internal/tui/styles.go b/core/internal/tui/styles.go index 45a9dae67..9033b26ac 100644 --- a/core/internal/tui/styles.go +++ b/core/internal/tui/styles.go @@ -1,7 +1,6 @@ package tui import ( - "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/lipgloss" ) @@ -20,19 +19,43 @@ type AppTheme struct { func TerminalTheme() AppTheme { return AppTheme{ - Primary: "6", // #625690 - purple - Secondary: "5", // #36247a - dark purple - Accent: "12", // #7060ac - light purple - Text: "7", // #2e2e2e - dark gray - Subtle: "8", // #4a4a4a - medium gray - Error: "1", // #d83636 - red - Warning: "3", // #ffff89 - yellow - Success: "2", // #53e550 - green - Background: "15", // #1a1a1a - near black - Surface: "8", // #4a4a4a - medium gray + Primary: "6", + Secondary: "5", + Accent: "12", + Text: "7", + Subtle: "8", + Error: "1", + Warning: "3", + Success: "2", + Background: "15", + Surface: "8", } } +type Styles struct { + Title lipgloss.Style + Normal lipgloss.Style + Bold lipgloss.Style + Subtle lipgloss.Style + SubtleItalic lipgloss.Style + Warning lipgloss.Style + Error lipgloss.Style + StatusBar lipgloss.Style + Key lipgloss.Style + SpinnerStyle lipgloss.Style + Success lipgloss.Style + HighlightButton lipgloss.Style + SelectedOption lipgloss.Style + CodeBlock lipgloss.Style + Accent lipgloss.Style + AccentItalic lipgloss.Style + Highlight lipgloss.Style + Banner lipgloss.Style + TitleBox lipgloss.Style + InfoBox lipgloss.Style + ErrorBox lipgloss.Style +} + func NewStyles(theme AppTheme) Styles { return Styles{ Title: lipgloss.NewStyle(). @@ -51,6 +74,10 @@ func NewStyles(theme AppTheme) Styles { Subtle: lipgloss.NewStyle(). Foreground(lipgloss.Color(theme.Subtle)), + SubtleItalic: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Subtle)). + Italic(true), + Error: lipgloss.NewStyle(). Foreground(lipgloss.Color(theme.Error)), @@ -88,37 +115,39 @@ func NewStyles(theme AppTheme) Styles { Foreground(lipgloss.Color(theme.Text)). Padding(1, 2). MarginLeft(2), + + Accent: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)), + + AccentItalic: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)). + Italic(true), + + Highlight: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true), + + Banner: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true). + MarginBottom(1), + + TitleBox: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(theme.Primary)). + Padding(0, 2). + MarginBottom(1), + + InfoBox: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(theme.Subtle)). + Padding(0, 1). + MarginBottom(1), + + ErrorBox: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(theme.Error)). + Padding(1, 2). + MarginBottom(1), } } - -type Styles struct { - Title lipgloss.Style - Normal lipgloss.Style - Bold lipgloss.Style - Subtle lipgloss.Style - Warning lipgloss.Style - Error lipgloss.Style - StatusBar lipgloss.Style - Key lipgloss.Style - SpinnerStyle lipgloss.Style - Success lipgloss.Style - HighlightButton lipgloss.Style - SelectedOption lipgloss.Style - CodeBlock lipgloss.Style -} - -func (s Styles) NewThemedProgress(width int) progress.Model { - theme := TerminalTheme() - prog := progress.New( - progress.WithGradient(theme.Secondary, theme.Primary), - ) - - prog.Width = width - prog.ShowPercentage = true - prog.PercentFormat = "%.0f%%" - prog.PercentageStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Text)). - Bold(true) - - return prog -} diff --git a/core/internal/tui/views_config.go b/core/internal/tui/views_config.go index dd527499e..983e4ce74 100644 --- a/core/internal/tui/views_config.go +++ b/core/internal/tui/views_config.go @@ -33,109 +33,57 @@ func (m Model) viewDeployingConfigs() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Deploying Configurations") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Deploying Configurations")) b.WriteString("\n\n") - - spinner := m.spinner.View() - status := m.styles.Normal.Render("Setting up configuration files...") - fmt.Fprintf(&b, "%s %s", spinner, status) + fmt.Fprintf(&b, "%s %s", m.spinner.View(), m.styles.Normal.Render("Setting up configuration files...")) b.WriteString("\n\n") + b.WriteString(m.styles.Subtle.Render("• Creating backups of existing configurations\n• Deploying optimized configurations\n• Detecting system paths")) - // Show progress information - info := m.styles.Subtle.Render("• Creating backups of existing configurations\n• Deploying optimized configurations\n• Detecting system paths") - b.WriteString(info) - - // Show live log output if available if len(m.installationLogs) > 0 { b.WriteString("\n\n") - logHeader := m.styles.Subtle.Render("Configuration Log:") - b.WriteString(logHeader) - b.WriteString("\n") - - // Show last few lines of logs - maxLines := 5 - startIdx := 0 - if len(m.installationLogs) > maxLines { - startIdx = len(m.installationLogs) - maxLines - } - - for i := startIdx; i < len(m.installationLogs); i++ { - if m.installationLogs[i] != "" { - logLine := m.styles.Subtle.Render(" " + m.installationLogs[i]) - b.WriteString(logLine) - b.WriteString("\n") - } - } + m.writeRecentLogs(&b, m.styles.Subtle.Render("Configuration Log:"), 5) } return b.String() } func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) { - if result, ok := msg.(configDeploymentResult); ok { - if result.error != nil { - m.err = result.error - m.state = StateError - m.isLoading = false - return m, nil - } + result, ok := msg.(configDeploymentResult) + if !ok { + return m, m.listenForLogs() + } - for _, deployResult := range result.results { - if deployResult.Deployed { - logMsg := fmt.Sprintf("✓ %s configuration deployed", deployResult.ConfigType) - if deployResult.BackupPath != "" { - logMsg += fmt.Sprintf(" (backup: %s)", deployResult.BackupPath) - } - m.installationLogs = append(m.installationLogs, logMsg) - } - } - - m.state = StateInstallComplete + if result.error != nil { + m.err = result.error + m.state = StateError m.isLoading = false return m, nil } - return m, m.listenForLogs() + for _, deployResult := range result.results { + if !deployResult.Deployed { + continue + } + logLine := fmt.Sprintf("✓ %s configuration deployed", deployResult.ConfigType) + if deployResult.BackupPath != "" { + logLine += fmt.Sprintf(" (backup: %s)", deployResult.BackupPath) + } + m.installationLogs = append(m.installationLogs, logLine) + } + + m.state = StateInstallComplete + m.isLoading = false + return m, nil } func (m Model) deployConfigurations() tea.Cmd { return func() tea.Msg { - // Determine the selected window manager - wm := m.selectedWindowManager() - - // Determine the selected terminal - var terminal deps.Terminal - if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { - switch m.selectedTerminal { - case 0: - terminal = deps.TerminalKitty - case 1: - terminal = deps.TerminalAlacritty - default: - terminal = deps.TerminalKitty - } - } else { - switch m.selectedTerminal { - case 0: - terminal = deps.TerminalGhostty - case 1: - terminal = deps.TerminalKitty - default: - terminal = deps.TerminalAlacritty - } - } - deployer := config.NewConfigDeployer(m.logChan) - - results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(context.Background(), wm, terminal, m.dependencies, m.replaceConfigs, m.reinstallItems, m.useSystemdConfig()) - - return configDeploymentResult{ - results: results, - error: err, - } + results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd( + context.Background(), m.chosenWindowManager(), m.chosenTerminal(), + m.dependencies, m.replaceConfigs, m.reinstallItems, m.useSystemdConfig()) + return configDeploymentResult{results: results, error: err} } } @@ -166,57 +114,35 @@ func (m Model) viewConfigConfirmation() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Configuration Deployment") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Configuration Deployment")) b.WriteString("\n\n") if len(m.existingConfigs) == 0 { - // No existing configs, proceed directly - info := m.styles.Normal.Render("No existing configurations found. Proceeding with deployment...") - b.WriteString(info) + b.WriteString(m.styles.Normal.Render("No existing configurations found. Proceeding with deployment...")) return b.String() } - // Show existing configurations with toggle options - for i, configInfo := range m.existingConfigs { - if configInfo.Exists { - var status string - var replaceMarker string - - shouldReplace := m.replaceConfigs[configInfo.ConfigType] - if _, exists := m.replaceConfigs[configInfo.ConfigType]; !exists { - shouldReplace = true - m.replaceConfigs[configInfo.ConfigType] = true - } - - if shouldReplace { - replaceMarker = "🔄 " - status = m.styles.Warning.Render("Will replace") - } else { - replaceMarker = "✓ " - status = m.styles.Success.Render("Keep existing") - } - - var line string - if i == m.selectedConfig { - line = fmt.Sprintf("▶ %s%-15s %s", replaceMarker, configInfo.ConfigType, status) - line += fmt.Sprintf("\n %s", configInfo.Path) - line = m.styles.SelectedOption.Render(line) - } else { - line = fmt.Sprintf(" %s%-15s %s", replaceMarker, configInfo.ConfigType, status) - line += fmt.Sprintf("\n %s", configInfo.Path) - line = m.styles.Normal.Render(line) - } - - b.WriteString(line) - b.WriteString("\n\n") + for i, cfg := range m.existingConfigs { + if !cfg.Exists { + continue } + + marker, status := "🔄 ", m.styles.Warning.Render("Will replace") + if !m.replaceConfigs[cfg.ConfigType] { + marker, status = "✓ ", m.styles.Success.Render("Keep existing") + } + + cursor, style := " ", m.styles.Normal + if i == m.selectedConfig { + cursor, style = "▶ ", m.styles.SelectedOption + } + + b.WriteString(style.Render(fmt.Sprintf("%s%s%-15s %s\n %s", cursor, marker, cfg.ConfigType, status, cfg.Path))) + b.WriteString("\n\n") } - backup := m.styles.Success.Render("✓ Replaced configurations will be backed up with timestamp") - b.WriteString(backup) + b.WriteString(m.styles.Success.Render("✓ Replaced configurations will be backed up with timestamp")) b.WriteString("\n\n") if note := m.configReplacementNote(); note != "" { @@ -224,9 +150,7 @@ func (m Model) viewConfigConfirmation() string { b.WriteString("\n\n") } - help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle replace/keep, Enter: Continue") - b.WriteString(help) - + b.WriteString(m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle replace/keep, Enter: Continue")) return b.String() } @@ -264,195 +188,105 @@ func (m Model) configReplacementNote() string { } func (m Model) updateConfigConfirmationState(msg tea.Msg) (tea.Model, tea.Cmd) { - if result, ok := msg.(configCheckResult); ok { - if result.error != nil { - m.err = result.error - m.state = StateError - return m, nil - } + switch msg := msg.(type) { + case configCheckResult: + return m.applyConfigCheck(msg) - m.existingConfigs = result.configs - - firstExistingSet := false - for i, config := range result.configs { - if config.Exists { - m.replaceConfigs[config.ConfigType] = true - if !firstExistingSet { - m.selectedConfig = i - firstExistingSet = true - } - } - } - - hasExisting := false - for _, config := range result.configs { - if config.Exists { - hasExisting = true - break - } - } - - if !hasExisting { - // No existing configs, proceed directly to deployment - m.state = StateDeployingConfigs - return m, m.deployConfigurations() - } - - // Show confirmation view - return m, nil - } - - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { + case tea.KeyMsg: + switch msg.String() { case "up": - if m.selectedConfig > 0 { - for i := m.selectedConfig - 1; i >= 0; i-- { - if m.existingConfigs[i].Exists { - m.selectedConfig = i - break - } - } - } + m.selectedConfig = m.nearestExistingConfig(m.selectedConfig, -1) case "down": - if m.selectedConfig < len(m.existingConfigs)-1 { - for i := m.selectedConfig + 1; i < len(m.existingConfigs); i++ { - if m.existingConfigs[i].Exists { - m.selectedConfig = i - break - } - } - } + m.selectedConfig = m.nearestExistingConfig(m.selectedConfig, 1) case " ": - if len(m.existingConfigs) > 0 && m.selectedConfig < len(m.existingConfigs) { + if m.selectedConfig < len(m.existingConfigs) && m.existingConfigs[m.selectedConfig].Exists { configType := m.existingConfigs[m.selectedConfig].ConfigType - if m.existingConfigs[m.selectedConfig].Exists { - m.replaceConfigs[configType] = !m.replaceConfigs[configType] - } + m.replaceConfigs[configType] = !m.replaceConfigs[configType] } case "enter": m.state = StateDeployingConfigs return m, m.deployConfigurations() } } - return m, nil } +func (m Model) applyConfigCheck(result configCheckResult) (tea.Model, tea.Cmd) { + if result.error != nil { + m.err = result.error + m.state = StateError + return m, nil + } + + m.existingConfigs = result.configs + + hasExisting := false + for i, cfg := range result.configs { + if !cfg.Exists { + continue + } + m.replaceConfigs[cfg.ConfigType] = true + if !hasExisting { + m.selectedConfig = i + } + hasExisting = true + } + + if !hasExisting { + m.state = StateDeployingConfigs + return m, m.deployConfigurations() + } + return m, nil +} + +func (m Model) nearestExistingConfig(from, direction int) int { + for i := from + direction; i >= 0 && i < len(m.existingConfigs); i += direction { + if m.existingConfigs[i].Exists { + return i + } + } + return from +} + +func statConfig(configType string, paths ...string) ExistingConfigInfo { + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return ExistingConfigInfo{ConfigType: configType, Path: path, Exists: true} + } + } + return ExistingConfigInfo{ConfigType: configType, Path: paths[0]} +} + +func (m Model) wmConfigInfo() ExistingConfigInfo { + configDir := filepath.Join(os.Getenv("HOME"), ".config") + switch m.chosenWindowManager() { + case deps.WindowManagerNiri: + return statConfig("Niri", filepath.Join(configDir, "niri", "config.kdl")) + case deps.WindowManagerMango: + return statConfig("Mango", + filepath.Join(configDir, "mango", "config.conf"), + filepath.Join(configDir, "mango", "mango.conf")) + default: + return statConfig("Hyprland", + filepath.Join(configDir, "hypr", "hyprland.lua"), + filepath.Join(configDir, "hypr", "hyprland.conf")) + } +} + +func (m Model) terminalConfigInfo() ExistingConfigInfo { + configDir := filepath.Join(os.Getenv("HOME"), ".config") + switch m.chosenTerminal() { + case deps.TerminalGhostty: + return statConfig("Ghostty", filepath.Join(configDir, "ghostty", "config")) + case deps.TerminalKitty: + return statConfig("Kitty", filepath.Join(configDir, "kitty", "kitty.conf")) + default: + return statConfig("Alacritty", filepath.Join(configDir, "alacritty", "alacritty.toml")) + } +} + func (m Model) checkExistingConfigurations() tea.Cmd { return func() tea.Msg { - var configs []ExistingConfigInfo - - switch m.selectedWindowManager() { - case deps.WindowManagerNiri: - niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl") - niriExists := false - if _, err := os.Stat(niriPath); err == nil { - niriExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Niri", - Path: niriPath, - Exists: niriExists, - }) - case deps.WindowManagerMango: - mangoConfPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf") - mangoMainPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf") - mangoPath := mangoConfPath - mangoExists := false - if _, err := os.Stat(mangoConfPath); err == nil { - mangoExists = true - } else if _, err := os.Stat(mangoMainPath); err == nil { - mangoPath = mangoMainPath - mangoExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Mango", - Path: mangoPath, - Exists: mangoExists, - }) - default: - hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua") - hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf") - hyprlandPath := hyprlandLuaPath - hyprlandExists := false - if _, err := os.Stat(hyprlandLuaPath); err == nil { - hyprlandExists = true - } else if _, err := os.Stat(hyprlandConfPath); err == nil { - hyprlandPath = hyprlandConfPath - hyprlandExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Hyprland", - Path: hyprlandPath, - Exists: hyprlandExists, - }) - } - - if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { - if m.selectedTerminal == 0 { - kittyPath := filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf") - kittyExists := false - if _, err := os.Stat(kittyPath); err == nil { - kittyExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Kitty", - Path: kittyPath, - Exists: kittyExists, - }) - } else { - alacrittyPath := filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml") - alacrittyExists := false - if _, err := os.Stat(alacrittyPath); err == nil { - alacrittyExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Alacritty", - Path: alacrittyPath, - Exists: alacrittyExists, - }) - } - } else { - switch m.selectedTerminal { - case 0: - ghosttyPath := filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config") - ghosttyExists := false - if _, err := os.Stat(ghosttyPath); err == nil { - ghosttyExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Ghostty", - Path: ghosttyPath, - Exists: ghosttyExists, - }) - case 1: - kittyPath := filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf") - kittyExists := false - if _, err := os.Stat(kittyPath); err == nil { - kittyExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Kitty", - Path: kittyPath, - Exists: kittyExists, - }) - default: - alacrittyPath := filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml") - alacrittyExists := false - if _, err := os.Stat(alacrittyPath); err == nil { - alacrittyExists = true - } - configs = append(configs, ExistingConfigInfo{ - ConfigType: "Alacritty", - Path: alacrittyPath, - Exists: alacrittyExists, - }) - } - } - - return configCheckResult{ - configs: configs, - error: nil, - } + return configCheckResult{configs: []ExistingConfigInfo{m.wmConfigInfo(), m.terminalConfigInfo()}} } } diff --git a/core/internal/tui/views_dependencies.go b/core/internal/tui/views_dependencies.go index e7dd18722..6e33a7b0a 100644 --- a/core/internal/tui/views_dependencies.go +++ b/core/internal/tui/views_dependencies.go @@ -11,19 +11,16 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +const dmsDepName = "dms (DankMaterialShell)" + func (m Model) viewDetectingDeps() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Detecting Dependencies") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Detecting Dependencies")) b.WriteString("\n\n") - - spinner := m.spinner.View() - status := m.styles.Normal.Render("Scanning system for existing packages and configurations...") - fmt.Fprintf(&b, "%s %s", spinner, status) + fmt.Fprintf(&b, "%s %s", m.spinner.View(), m.styles.Normal.Render("Scanning system for existing packages and configurations...")) return b.String() } @@ -47,283 +44,257 @@ func (m Model) viewDependencyReview() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Dependency Review") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Dependency Review")) b.WriteString("\n\n") - if len(m.dependencies) > 0 { - optionalHeaderShown := false - for i, dep := range m.dependencies { - if !dep.Required && !optionalHeaderShown { - b.WriteString("\n") - b.WriteString(m.styles.Subtle.Render("Optional (space to enable)")) - b.WriteString("\n") - optionalHeaderShown = true - } - - var status string - var reinstallMarker string - var variantMarker string - - isDMS := dep.Name == "dms (DankMaterialShell)" - - if dep.CanToggle && dep.Variant == deps.VariantGit { - variantMarker = "[git] " - } - - if m.disabledItems[dep.Name] { - reinstallMarker = "✗ " - status = m.styles.Subtle.Render("Will skip") - } else if m.reinstallItems[dep.Name] { - reinstallMarker = "🔄 " - status = m.styles.Warning.Render("Will upgrade") - } else if isDMS { - reinstallMarker = "⚡ " - switch dep.Status { - case deps.StatusInstalled: - status = m.styles.Success.Render("✓ Required (installed)") - case deps.StatusMissing: - status = m.styles.Warning.Render("○ Required (will install)") - case deps.StatusNeedsUpdate: - status = m.styles.Warning.Render("△ Required (needs update)") - case deps.StatusNeedsReinstall: - status = m.styles.Error.Render("! Required (needs reinstall)") - } - } else { - switch dep.Status { - case deps.StatusInstalled: - status = m.styles.Subtle.Render("✓ Already installed") - case deps.StatusMissing: - status = m.styles.Warning.Render("○ Will install") - case deps.StatusNeedsUpdate: - status = m.styles.Warning.Render("△ Will install") - case deps.StatusNeedsReinstall: - status = m.styles.Error.Render("! Will install") - } - } - - note := "" - switch dep.Name { - case "dms-greeter": - note = m.styles.Subtle.Render(" (selection replaces your current display manager)") - case "danksearch": - note = m.styles.Subtle.Render(" (file search; enables dsearch.service)") - case "dankcalendar": - note = m.styles.Subtle.Render(" (autostart managed in dankcalendar settings)") - } - - var line string - if i == m.selectedDep { - line = fmt.Sprintf("▶ %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status) - if dep.Version != "" { - line += fmt.Sprintf(" (%s)", dep.Version) - } - line = m.styles.SelectedOption.Render(line) + note - } else { - line = fmt.Sprintf(" %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status) - if dep.Version != "" { - line += fmt.Sprintf(" (%s)", dep.Version) - } - line = m.styles.Normal.Render(line) + note - } - - b.WriteString(line) - b.WriteString("\n") + optionalHeaderShown := false + for i, dep := range m.dependencies { + if !dep.Required && !optionalHeaderShown { + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("Optional (space to enable)")) + b.WriteByte('\n') + optionalHeaderShown = true } + b.WriteString(m.dependencyLine(dep, i == m.selectedDep)) + b.WriteByte('\n') } - b.WriteString("\n") - help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle, G: Toggle stable/git, Enter: Continue") - b.WriteString(help) - + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle, G: Toggle stable/git, Enter: Continue")) return b.String() } -func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) { - if depsMsg, ok := msg.(depsDetectedMsg); ok { - m.isLoading = false - if depsMsg.err != nil { - m.err = depsMsg.err - m.state = StateError - } else { - m.dependencies = partitionOptionalLast(depsMsg.deps) - // Optional components are opt-in, skipped by default - for _, dep := range m.dependencies { - if dep.Required { - continue - } - m.disabledItems[dep.Name] = true - } - m.state = StateDependencyReview +func (m Model) dependencyLine(dep deps.Dependency, selected bool) string { + marker, status := m.dependencyStatus(dep) + + variant := "" + if dep.CanToggle && dep.Variant == deps.VariantGit { + variant = "[git] " + } + + cursor, style := " ", m.styles.Normal + if selected { + cursor, style = "▶ ", m.styles.SelectedOption + } + + line := fmt.Sprintf("%s%s%s%-25s %s", cursor, marker, variant, dep.Name, status) + if dep.Version != "" { + line += fmt.Sprintf(" (%s)", dep.Version) + } + return style.Render(line) + m.dependencyNote(dep.Name) +} + +func (m Model) dependencyStatus(dep deps.Dependency) (marker, status string) { + switch { + case m.disabledItems[dep.Name]: + return "✗ ", m.styles.Subtle.Render("Will skip") + case m.reinstallItems[dep.Name]: + return "🔄 ", m.styles.Warning.Render("Will upgrade") + case dep.Name == dmsDepName: + switch dep.Status { + case deps.StatusInstalled: + return "⚡ ", m.styles.Success.Render("✓ Required (installed)") + case deps.StatusMissing: + return "⚡ ", m.styles.Warning.Render("○ Required (will install)") + case deps.StatusNeedsUpdate: + return "⚡ ", m.styles.Warning.Render("△ Required (needs update)") + case deps.StatusNeedsReinstall: + return "⚡ ", m.styles.Error.Render("! Required (needs reinstall)") } + return "⚡ ", "" + } + + switch dep.Status { + case deps.StatusInstalled: + return "", m.styles.Subtle.Render("✓ Already installed") + case deps.StatusMissing: + return "", m.styles.Warning.Render("○ Will install") + case deps.StatusNeedsUpdate: + return "", m.styles.Warning.Render("△ Will install") + case deps.StatusNeedsReinstall: + return "", m.styles.Error.Render("! Will install") + } + return "", "" +} + +func (m Model) dependencyNote(name string) string { + switch name { + case "dms-greeter": + return m.styles.Subtle.Render(" (selection replaces your current display manager)") + case "danksearch": + return m.styles.Subtle.Render(" (file search; enables dsearch.service)") + case "dankcalendar": + return m.styles.Subtle.Render(" (autostart managed in dankcalendar settings)") + default: + return "" + } +} + +func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) { + depsMsg, ok := msg.(depsDetectedMsg) + if !ok { return m, m.listenForLogs() } + + m.isLoading = false + if depsMsg.err != nil { + m.err = depsMsg.err + m.state = StateError + return m, m.listenForLogs() + } + + m.dependencies = partitionOptionalLast(depsMsg.deps) + for _, dep := range m.dependencies { + if dep.Required { + continue + } + m.disabledItems[dep.Name] = true + } + m.state = StateDependencyReview return m, m.listenForLogs() } func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "up": - if m.selectedDep > 0 { - m.selectedDep-- - } - case "down": - if m.selectedDep < len(m.dependencies)-1 { - m.selectedDep++ - } - case " ": - if len(m.dependencies) > 0 { - depName := m.dependencies[m.selectedDep].Name - isDMS := depName == "dms (DankMaterialShell)" + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, m.listenForLogs() + } - if !isDMS { - isInstalled := m.dependencies[m.selectedDep].Status == deps.StatusInstalled || - m.dependencies[m.selectedDep].Status == deps.StatusNeedsReinstall - - if isInstalled { - m.reinstallItems[depName] = !m.reinstallItems[depName] - m.disabledItems[depName] = false - } else { - m.disabledItems[depName] = !m.disabledItems[depName] - m.reinstallItems[depName] = false - } - } + switch keyMsg.String() { + case "up": + m.selectedDep = moveIndex(m.selectedDep, -1, len(m.dependencies)-1) + case "down": + m.selectedDep = moveIndex(m.selectedDep, 1, len(m.dependencies)-1) + case " ": + m.toggleSelectedDependency() + case "g", "G": + m.toggleSelectedVariant() + case "enter": + if m.osInfo != nil { + if config, ok := distros.Registry[m.osInfo.Distribution.ID]; ok && config.Family == distros.FamilyGentoo { + m.state = StateGentooUseFlags + return m, nil } - case "g", "G": - if len(m.dependencies) > 0 && m.dependencies[m.selectedDep].CanToggle { - if m.dependencies[m.selectedDep].Variant == deps.VariantStable { - m.dependencies[m.selectedDep].Variant = deps.VariantGit - } else { - m.dependencies[m.selectedDep].Variant = deps.VariantStable - } - } - case "enter": - // Check if on Gentoo - show USE flags screen - if m.osInfo != nil { - if config, exists := distros.Registry[m.osInfo.Distribution.ID]; exists && config.Family == distros.FamilyGentoo { - m.state = StateGentooUseFlags - return m, nil - } - } - return m.enterAuthPhase() - case "esc": - m.state = StateSelectWindowManager - return m, nil } + return m.enterAuthPhase() + case "esc": + m.state = StateSelectWindowManager + return m, nil } return m, m.listenForLogs() } +func (m Model) toggleSelectedDependency() { + if len(m.dependencies) == 0 { + return + } + + dep := m.dependencies[m.selectedDep] + if dep.Name == dmsDepName { + return + } + + installed := dep.Status == deps.StatusInstalled || dep.Status == deps.StatusNeedsReinstall + if installed { + m.reinstallItems[dep.Name] = !m.reinstallItems[dep.Name] + m.disabledItems[dep.Name] = false + return + } + m.disabledItems[dep.Name] = !m.disabledItems[dep.Name] + m.reinstallItems[dep.Name] = false +} + +func (m Model) toggleSelectedVariant() { + if len(m.dependencies) == 0 || !m.dependencies[m.selectedDep].CanToggle { + return + } + + if m.dependencies[m.selectedDep].Variant == deps.VariantStable { + m.dependencies[m.selectedDep].Variant = deps.VariantGit + return + } + m.dependencies[m.selectedDep].Variant = deps.VariantStable +} + func (m Model) installPackages() tea.Cmd { return func() tea.Msg { if m.osInfo == nil { - return packageInstallProgressMsg{ - progress: 0.0, - step: "Error: OS info not available", - isComplete: true, - } + return packageInstallProgressMsg{step: "Error: OS info not available", isComplete: true} } installer, err := distros.NewPackageInstaller(m.osInfo.Distribution.ID, m.logChan) if err != nil { - return packageInstallProgressMsg{ - progress: 0.0, - step: fmt.Sprintf("Error: %s", err.Error()), - isComplete: true, - } + return packageInstallProgressMsg{step: fmt.Sprintf("Error: %s", err.Error()), isComplete: true} } - // Convert TUI selection to deps enum - wm := m.selectedWindowManager() - - installerProgressChan := make(chan distros.InstallProgressMsg, 100) - + progressChan := make(chan distros.InstallProgressMsg, 100) go func() { - defer close(installerProgressChan) - err := installer.InstallPackages(context.Background(), m.dependencies, wm, m.sudoPassword, m.reinstallItems, m.disabledItems, m.skipGentooUseFlags, installerProgressChan) + defer close(progressChan) + err := installer.InstallPackages(context.Background(), m.dependencies, m.chosenWindowManager(), m.sudoPassword, m.reinstallItems, m.disabledItems, m.skipGentooUseFlags, progressChan) if err != nil { - installerProgressChan <- distros.InstallProgressMsg{ - Progress: 0.0, + progressChan <- distros.InstallProgressMsg{ Step: fmt.Sprintf("Installation error: %s", err.Error()), IsComplete: true, Error: err, } } }() + go m.forwardInstallProgress(progressChan) - // Convert installer messages to TUI messages - go func() { - for msg := range installerProgressChan { - // Run optional greeter setup - if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil { - if m.optionalDepSelected("dms-greeter") { - m.packageProgressChan <- packageInstallProgressMsg{ - progress: 0.92, - step: "Configuring DMS greeter...", - logOutput: "Starting automated greeter setup...", - } - greeterLogFunc := func(line string) { - m.packageProgressChan <- packageInstallProgressMsg{ - progress: 0.94, - step: "Configuring DMS greeter...", - logOutput: line, - } - } - if err := utils.RunDmsGreeterInstall(m.sudoPassword, greeterLogFunc); err != nil { - m.packageProgressChan <- packageInstallProgressMsg{ - progress: 0.96, - step: "Greeter setup warning", - logOutput: fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err), - } - } + return packageInstallProgressMsg{progress: 0.05, step: "Starting installation..."} + } +} - if m.useSystemdConfig() && m.optionalDepSelected("danksearch") { - m.packageProgressChan <- packageInstallProgressMsg{ - progress: 0.97, - step: "Enabling danksearch service...", - logOutput: "Setting up dsearch.service...", - } - dsearchLogFunc := func(line string) { - m.packageProgressChan <- packageInstallProgressMsg{ - progress: 0.97, - step: "Enabling danksearch service...", - logOutput: line, - } - } - if err := distros.SetupDsearchService(context.Background(), dsearchLogFunc); err != nil { - m.packageProgressChan <- packageInstallProgressMsg{ - progress: 0.98, - step: "danksearch service warning", - logOutput: fmt.Sprintf("danksearch service setup warning (non-fatal): %v", err), - } - } - } - } - } - tuiMsg := packageInstallProgressMsg{ - progress: msg.Progress, - step: msg.Step, - isComplete: msg.IsComplete, - needsSudo: msg.NeedsSudo, - commandInfo: msg.CommandInfo, - logOutput: msg.LogOutput, - error: msg.Error, - } - if msg.IsComplete { - m.logChan <- fmt.Sprintf("[DEBUG] Sending completion signal: step=%s, progress=%.2f", msg.Step, msg.Progress) - } - m.packageProgressChan <- tuiMsg - } - m.logChan <- "[DEBUG] Installer channel closed" - }() +func (m Model) forwardInstallProgress(progressChan <-chan distros.InstallProgressMsg) { + for msg := range progressChan { + if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil { + m.runPostInstallTasks() + } + + if msg.IsComplete { + m.logChan <- fmt.Sprintf("[DEBUG] Sending completion signal: step=%s, progress=%.2f", msg.Step, msg.Progress) + } + m.packageProgressChan <- packageInstallProgressMsg{ + progress: msg.Progress, + step: msg.Step, + isComplete: msg.IsComplete, + needsSudo: msg.NeedsSudo, + commandInfo: msg.CommandInfo, + logOutput: msg.LogOutput, + error: msg.Error, + } + } + m.logChan <- "[DEBUG] Installer channel closed" +} - return packageInstallProgressMsg{ - progress: 0.05, - step: "Starting installation...", - isComplete: false, +func (m Model) runPostInstallTasks() { + if m.optionalDepSelected("dms-greeter") { + m.reportProgress(0.92, "Configuring DMS greeter...", "Starting automated greeter setup...") + logLine := func(line string) { + m.reportProgress(0.94, "Configuring DMS greeter...", line) } + if err := utils.RunDmsGreeterInstall(m.sudoPassword, logLine); err != nil { + m.reportProgress(0.96, "Greeter setup warning", fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err)) + } + } + + if !m.useSystemdConfig() || !m.optionalDepSelected("danksearch") { + return + } + m.reportProgress(0.97, "Enabling danksearch service...", "Setting up dsearch.service...") + logLine := func(line string) { + m.reportProgress(0.97, "Enabling danksearch service...", line) + } + if err := distros.SetupDsearchService(context.Background(), logLine); err != nil { + m.reportProgress(0.98, "danksearch service warning", fmt.Sprintf("danksearch service setup warning (non-fatal): %v", err)) + } +} + +func (m Model) reportProgress(progress float64, step, logOutput string) { + m.packageProgressChan <- packageInstallProgressMsg{ + progress: progress, + step: step, + logOutput: logOutput, } } diff --git a/core/internal/tui/views_gentoo_gcc.go b/core/internal/tui/views_gentoo_gcc.go index 74195a44a..0b58777e1 100644 --- a/core/internal/tui/views_gentoo_gcc.go +++ b/core/internal/tui/views_gentoo_gcc.go @@ -11,31 +11,22 @@ import ( ) type gccVersionCheckMsg struct { - version string - major int - err error + major int + err error } func (m Model) viewGentooGCCCheck() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("GCC Version Check Failed") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("GCC Version Check Failed")) b.WriteString("\n\n") - - error := m.styles.Error.Render("⚠ Hyprland requires GCC 15 or newer") - b.WriteString(error) + b.WriteString(m.styles.Error.Render("⚠ Hyprland requires GCC 15 or newer")) b.WriteString("\n\n") - - info := m.styles.Normal.Render("Your current GCC version is too old. Please upgrade GCC before continuing.") - b.WriteString(info) + b.WriteString(m.styles.Normal.Render("Your current GCC version is too old. Please upgrade GCC before continuing.")) b.WriteString("\n\n") - - instructionsTitle := m.styles.Subtle.Render("To upgrade GCC:") - b.WriteString(instructionsTitle) + b.WriteString(m.styles.Subtle.Render("To upgrade GCC:")) b.WriteString("\n\n") steps := []string{ @@ -50,35 +41,27 @@ func (m Model) viewGentooGCCCheck() string { "", "4. Restart this installer", } - for _, step := range steps { - stepLine := m.styles.Subtle.Render(step) - b.WriteString(stepLine) - b.WriteString("\n") + b.WriteString(m.styles.Subtle.Render(step)) + b.WriteByte('\n') } - b.WriteString("\n") - help := m.styles.Subtle.Render("Press Esc to go back, Ctrl+C to exit") - b.WriteString(help) - + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("Press Esc to go back, Ctrl+C to exit")) return b.String() } func (m Model) updateGentooGCCCheckState(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "esc": - m.state = StateSelectWindowManager - return m, nil - } + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" { + m.state = StateSelectWindowManager + return m, nil } return m, m.listenForLogs() } func (m Model) checkGCCVersion() tea.Cmd { return func() tea.Msg { - cmd := exec.CommandContext(context.Background(), "gcc", "-dumpversion") - output, err := cmd.Output() + output, err := exec.CommandContext(context.Background(), "gcc", "-dumpversion").Output() if err != nil { return gccVersionCheckMsg{err: err} } @@ -93,11 +76,6 @@ func (m Model) checkGCCVersion() tea.Cmd { if err != nil { return gccVersionCheckMsg{err: err} } - - return gccVersionCheckMsg{ - version: version, - major: major, - err: nil, - } + return gccVersionCheckMsg{major: major} } } diff --git a/core/internal/tui/views_gentoo_use_flags.go b/core/internal/tui/views_gentoo_use_flags.go index 8601823e8..ee272947c 100644 --- a/core/internal/tui/views_gentoo_use_flags.go +++ b/core/internal/tui/views_gentoo_use_flags.go @@ -13,41 +13,29 @@ func (m Model) viewGentooUseFlags() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Gentoo Global USE Flags") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Gentoo Global USE Flags")) b.WriteString("\n\n") - - info := m.styles.Normal.Render("The following global USE flags will be enabled in /etc/portage/make.conf:") - b.WriteString(info) + b.WriteString(m.styles.Normal.Render("The following global USE flags will be enabled in /etc/portage/make.conf:")) b.WriteString("\n\n") for _, flag := range distros.GentooGlobalUseFlags { - flagLine := m.styles.Success.Render(fmt.Sprintf(" • %s", flag)) - b.WriteString(flagLine) - b.WriteString("\n") + b.WriteString(m.styles.Success.Render(fmt.Sprintf(" • %s", flag))) + b.WriteByte('\n') } - b.WriteString("\n") - note := m.styles.Subtle.Render("These flags ensure proper Qt6, Wayland, and compositor support.") - b.WriteString(note) + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("These flags ensure proper Qt6, Wayland, and compositor support.")) b.WriteString("\n\n") - var toggleLine string + toggle := m.styles.Subtle.Render(" [ ] Skip adding global USE flags (will use existing configuration)") if m.skipGentooUseFlags { - toggleLine = "▶ [✗] Skip adding global USE flags (will use existing configuration)" - toggleLine = m.styles.Warning.Render(toggleLine) - } else { - toggleLine = " [ ] Skip adding global USE flags (will use existing configuration)" - toggleLine = m.styles.Subtle.Render(toggleLine) + toggle = m.styles.Warning.Render("▶ [✗] Skip adding global USE flags (will use existing configuration)") } - b.WriteString(toggleLine) + b.WriteString(toggle) b.WriteString("\n\n") - help := m.styles.Subtle.Render("Space: Toggle skip, Enter: Continue, Esc: Go back") - b.WriteString(help) - + b.WriteString(m.styles.Subtle.Render("Space: Toggle skip, Enter: Continue, Esc: Go back")) return b.String() } @@ -66,7 +54,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) { m.skipGentooUseFlags = !m.skipGentooUseFlags return m, nil case "enter": - if m.selectedWindowManager() == deps.WindowManagerHyprland { + if m.chosenWindowManager() == deps.WindowManagerHyprland { return m, m.checkGCCVersion() } return m.enterAuthPhase() diff --git a/core/internal/tui/views_install.go b/core/internal/tui/views_install.go index 81bcdbbca..8f287982a 100644 --- a/core/internal/tui/views_install.go +++ b/core/internal/tui/views_install.go @@ -7,119 +7,104 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) -// wrapText wraps text to the specified width func wrapText(text string, width int) string { if len(text) <= width { return text } var result strings.Builder - words := strings.Fields(text) currentLine := "" - - for _, word := range words { - if len(currentLine) == 0 { + for word := range strings.FieldsSeq(text) { + switch { + case currentLine == "": currentLine = word - } else if len(currentLine)+1+len(word) <= width { + case len(currentLine)+1+len(word) <= width: currentLine += " " + word - } else { + default: result.WriteString(currentLine) - result.WriteString("\n") + result.WriteByte('\n') currentLine = word } } + result.WriteString(currentLine) + return result.String() +} - if len(currentLine) > 0 { - result.WriteString(currentLine) +func tailLines(lines []string, n int) []string { + if len(lines) <= n { + return lines + } + return lines[len(lines)-n:] +} + +func (m Model) writeRecentLogs(b *strings.Builder, renderedHeader string, maxLines int) { + if len(m.installationLogs) == 0 { + return } - return result.String() + b.WriteString(renderedHeader) + b.WriteByte('\n') + for _, line := range tailLines(m.installationLogs, maxLines) { + if line == "" { + continue + } + b.WriteString(m.styles.Subtle.Render(" " + line)) + b.WriteByte('\n') + } } func (m Model) viewInstallingPackages() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Installing Packages") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Installing Packages")) b.WriteString("\n\n") - if !m.packageProgress.isComplete { - spinner := m.spinner.View() - status := m.styles.Normal.Render(m.packageProgress.step) - fmt.Fprintf(&b, "%s %s", spinner, status) - b.WriteString("\n\n") - - // Show progress bar - progressBar := fmt.Sprintf("[%s%s] %.0f%%", - strings.Repeat("█", int(m.packageProgress.progress*30)), - strings.Repeat("░", 30-int(m.packageProgress.progress*30)), - m.packageProgress.progress*100) - b.WriteString(m.styles.Normal.Render(progressBar)) - b.WriteString("\n") - - // Show command info if available - if m.packageProgress.commandInfo != "" { - cmdInfo := m.styles.Subtle.Render("$ " + m.packageProgress.commandInfo) - b.WriteString(cmdInfo) - b.WriteString("\n") - } - - // Show live log output - if len(m.installationLogs) > 0 { - b.WriteString("\n") - logHeader := m.styles.Subtle.Render("Live Output:") - b.WriteString(logHeader) - b.WriteString("\n") - - // Show last few lines of accumulated logs - maxLines := 8 - startIdx := 0 - if len(m.installationLogs) > maxLines { - startIdx = len(m.installationLogs) - maxLines - } - - for i := startIdx; i < len(m.installationLogs); i++ { - if m.installationLogs[i] != "" { - logLine := m.styles.Subtle.Render(" " + m.installationLogs[i]) - b.WriteString(logLine) - b.WriteString("\n") - } - } - } - - // Show error if any + if m.packageProgress.isComplete { if m.packageProgress.error != nil { - b.WriteString("\n") - wrappedErrorMsg := wrapText("Error: "+m.packageProgress.error.Error(), 80) - errorMsg := m.styles.Error.Render(wrappedErrorMsg) - b.WriteString(errorMsg) + b.WriteString(m.styles.Error.Render(wrapText("✗ Installation failed: "+m.packageProgress.error.Error(), 80))) + return b.String() } + b.WriteString(m.styles.Success.Render("✓ Installation complete!")) + return b.String() + } - // Show sudo prompt if needed - if m.packageProgress.needsSudo { - sudoWarning := m.styles.Warning.Render("⚠ Using provided sudo password") - b.WriteString(sudoWarning) - } - } else { - if m.packageProgress.error != nil { - wrappedFailedMsg := wrapText("✗ Installation failed: "+m.packageProgress.error.Error(), 80) - errorMsg := m.styles.Error.Render(wrappedFailedMsg) - b.WriteString(errorMsg) - } else { - success := m.styles.Success.Render("✓ Installation complete!") - b.WriteString(success) - } + fmt.Fprintf(&b, "%s %s\n\n", m.spinner.View(), m.styles.Normal.Render(m.packageProgress.step)) + b.WriteString(m.styles.Normal.Render(m.renderInstallProgressBar())) + b.WriteByte('\n') + + if m.packageProgress.commandInfo != "" { + b.WriteString(m.styles.Subtle.Render("$ " + m.packageProgress.commandInfo)) + b.WriteByte('\n') + } + + if len(m.installationLogs) > 0 { + b.WriteByte('\n') + m.writeRecentLogs(&b, m.styles.Subtle.Render("Live Output:"), 8) + } + + if m.packageProgress.error != nil { + b.WriteByte('\n') + b.WriteString(m.styles.Error.Render(wrapText("Error: "+m.packageProgress.error.Error(), 80))) + } + + if m.packageProgress.needsSudo { + b.WriteString(m.styles.Warning.Render("⚠ Using provided sudo password")) } return b.String() } +func (m Model) renderInstallProgressBar() string { + const barWidth = 30 + filled := min(max(int(m.packageProgress.progress*barWidth), 0), barWidth) + return fmt.Sprintf("[%s%s] %.0f%%", + strings.Repeat("█", filled), strings.Repeat("░", barWidth-filled), m.packageProgress.progress*100) +} + func dmsPackageName(distroID string, dependencies []deps.Dependency) string { config, ok := distros.Registry[distroID] if !ok { @@ -128,7 +113,7 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string { var isGit bool for _, dep := range dependencies { - if dep.Name == "dms (DankMaterialShell)" { + if dep.Name == dmsDepName { isGit = dep.Variant == deps.VariantGit break } @@ -158,6 +143,7 @@ func uninstallCommand(distroID string, dependencies []deps.Dependency) string { if config.Family == distros.FamilyGentoo { return "sudo emerge --deselect gui-apps/dankmaterialshell && sudo emerge --depclean gui-apps/dankmaterialshell" } + pkg := dmsPackageName(distroID, dependencies) switch config.Family { case distros.FamilyArch: @@ -175,18 +161,60 @@ func uninstallCommand(distroID string, dependencies []deps.Dependency) string { } } +func (m Model) loginHint() string { + wm := m.chosenWindowManager() + + if !m.useSystemdConfig() { + switch wm { + case deps.WindowManagerNiri: + return "If you do not have a greeter, from a TTY run: dbus-run-session niri" + case deps.WindowManagerHyprland: + return "If you do not have a greeter, from a TTY run: dbus-run-session Hyprland" + default: + return "If you do not have a greeter, from a TTY run: dbus-run-session mango" + } + } + + switch wm { + case deps.WindowManagerNiri: + return `If you do not have a greeter, login with "niri-session"` + case deps.WindowManagerHyprland: + return `If you do not have a greeter, login with "Hyprland"` + default: + return `If you do not have a greeter, login with "mango"` + } +} + +func (m Model) troubleshootingHints() (autostart, logs string) { + wm := m.chosenWindowManager() + + if !m.useSystemdConfig() { + logs = "quickshell --path ~/.config/quickshell/dms log" + switch wm { + case deps.WindowManagerNiri: + autostart = `remove spawn-at-startup "dms" "run" from ~/.config/niri/config.kdl` + case deps.WindowManagerHyprland: + autostart = `remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua` + default: + autostart = "remove 'exec-once=dms run' from ~/.config/mango/config.conf" + } + return autostart, logs + } + + if wm == deps.WindowManagerMango { + return "remove 'exec-once=dms run' from ~/.config/mango/config.conf", "qs -p ~/.config/quickshell/dms log" + } + return "systemctl --user disable dms", "journalctl --user -u dms" +} + func (m Model) viewInstallComplete() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Success.Render("Setup Complete!") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Success.Render("Setup Complete!")) b.WriteString("\n\n") - - success := m.styles.Success.Render("✓ All packages installed and configurations deployed.") - b.WriteString(success) + b.WriteString(m.styles.Success.Render("✓ All packages installed and configurations deployed.")) b.WriteString("\n\n") accomplishments := []string{ @@ -195,77 +223,35 @@ func (m Model) viewInstallComplete() string { "• Configuration files deployed with backups", "• System optimized for DankMaterialShell", } - for _, item := range accomplishments { b.WriteString(m.styles.Subtle.Render(item)) - b.WriteString("\n") + b.WriteByte('\n') } - wm := m.selectedWindowManager() - - loginHint := "If you do not have a greeter, login with \"niri-session\" or \"Hyprland\"" - if !m.useSystemdConfig() { - switch wm { - case deps.WindowManagerNiri: - loginHint = "If you do not have a greeter, from a TTY run: dbus-run-session niri" - case deps.WindowManagerHyprland: - loginHint = "If you do not have a greeter, from a TTY run: dbus-run-session Hyprland" - case deps.WindowManagerMango: - loginHint = "If you do not have a greeter, from a TTY run: dbus-run-session mango" - } - } else { - // mango launches DMS via `exec-once=dms run` (not a systemd session target) - switch wm { - case deps.WindowManagerNiri: - loginHint = "If you do not have a greeter, login with \"niri-session\"" - case deps.WindowManagerHyprland: - loginHint = "If you do not have a greeter, login with \"Hyprland\"" - case deps.WindowManagerMango: - loginHint = "If you do not have a greeter, login with \"mango\"" - } - } - - b.WriteString("\n") - info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + loginHint) - b.WriteString(info) + b.WriteByte('\n') + b.WriteString(m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + m.loginHint())) b.WriteString("\n\n") - theme := TerminalTheme() - cmdStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Accent)) - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle)) + label := m.styles.Subtle + cmd := m.styles.Accent + autostart, logs := m.troubleshootingHints() - b.WriteString(labelStyle.Render("Troubleshooting:") + "\n") - if !m.useSystemdConfig() { - switch wm { - case deps.WindowManagerNiri: - b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render(`remove spawn-at-startup "dms" "run" from ~/.config/niri/config.kdl`) + "\n") - case deps.WindowManagerHyprland: - b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render(`remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua`) + "\n") - case deps.WindowManagerMango: - b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n") - } - b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("quickshell --path ~/.config/quickshell/dms log") + "\n") - } else if wm == deps.WindowManagerMango { - b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n") - b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("qs -p ~/.config/quickshell/dms log") + "\n") - } else { - b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n") - b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n") - } + b.WriteString(label.Render("Troubleshooting:") + "\n") + b.WriteString(label.Render(" Disable autostart: ") + cmd.Render(autostart) + "\n") + b.WriteString(label.Render(" View logs: ") + cmd.Render(logs) + "\n") if m.osInfo != nil { - if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" { - b.WriteString(labelStyle.Render(" Uninstall: ") + cmdStyle.Render(cmd) + "\n") + if uninstall := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); uninstall != "" { + b.WriteString(label.Render(" Uninstall: ") + cmd.Render(uninstall) + "\n") } } - b.WriteString("\n") + b.WriteByte('\n') b.WriteString(m.styles.Normal.Render("Press Enter to exit.")) if m.logFilePath != "" { b.WriteString("\n\n") - logInfo := m.styles.Subtle.Render(fmt.Sprintf("Full logs: %s", m.logFilePath)) - b.WriteString(logInfo) + b.WriteString(m.styles.Subtle.Render(fmt.Sprintf("Full logs: %s", m.logFilePath))) } return b.String() @@ -275,111 +261,77 @@ func (m Model) viewError() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Error.Render("Installation Failed") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Error.Render("Installation Failed")) b.WriteString("\n\n") if m.err != nil { - wrappedError := wrapText("✗ "+m.err.Error(), 80) - error := m.styles.Error.Render(wrappedError) - b.WriteString(error) + b.WriteString(m.styles.Error.Render(wrapText("✗ "+m.err.Error(), 80))) b.WriteString("\n\n") } - // Show package progress error if available if m.packageProgress.error != nil { - wrappedPackageError := wrapText("Package Installation Error: "+m.packageProgress.error.Error(), 80) - packageError := m.styles.Error.Render(wrappedPackageError) - b.WriteString(packageError) + b.WriteString(m.styles.Error.Render(wrapText("Package Installation Error: "+m.packageProgress.error.Error(), 80))) b.WriteString("\n\n") } - // Show persistent installation logs if len(m.installationLogs) > 0 { - logHeader := m.styles.Warning.Render("Installation Logs (last 15 lines):") - b.WriteString(logHeader) - b.WriteString("\n") - - maxLines := 15 - startIdx := 0 - if len(m.installationLogs) > maxLines { - startIdx = len(m.installationLogs) - maxLines - } - - for i := startIdx; i < len(m.installationLogs); i++ { - if m.installationLogs[i] != "" { - logLine := m.styles.Subtle.Render(" " + m.installationLogs[i]) - b.WriteString(logLine) - b.WriteString("\n") - } - } - b.WriteString("\n") + m.writeRecentLogs(&b, m.styles.Warning.Render("Installation Logs (last 15 lines):"), 15) + b.WriteByte('\n') } - hint := m.styles.Subtle.Render("Press Ctrl+D for full debug logs") - b.WriteString(hint) - b.WriteString("\n") + b.WriteString(m.styles.Subtle.Render("Press Ctrl+D for full debug logs")) + b.WriteByte('\n') if m.logFilePath != "" { - b.WriteString("\n") - logInfo := m.styles.Warning.Render(fmt.Sprintf("Full logs: %s", m.logFilePath)) - b.WriteString(logInfo) - b.WriteString("\n") + b.WriteByte('\n') + b.WriteString(m.styles.Warning.Render(fmt.Sprintf("Full logs: %s", m.logFilePath))) + b.WriteByte('\n') } - help := m.styles.Subtle.Render("Press Enter to exit") - b.WriteString(help) - + b.WriteString(m.styles.Subtle.Render("Press Enter to exit")) return b.String() } func (m Model) updateInstallingPackagesState(msg tea.Msg) (tea.Model, tea.Cmd) { - if progressMsg, ok := msg.(packageInstallProgressMsg); ok { - m.packageProgress = progressMsg + progressMsg, ok := msg.(packageInstallProgressMsg) + if !ok { + return m, m.listenForLogs() + } - // Accumulate log output - if progressMsg.logOutput != "" { - m.installationLogs = append(m.installationLogs, progressMsg.logOutput) - // Keep only last 50 lines to preserve more context for debugging - if len(m.installationLogs) > 50 { - m.installationLogs = m.installationLogs[len(m.installationLogs)-50:] - } - } + m.packageProgress = progressMsg - if progressMsg.isComplete { - if progressMsg.error != nil { - m.state = StateError - m.isLoading = false - } else { - m.installationLogs = []string{} - m.state = StateConfigConfirmation - m.isLoading = true - return m, tea.Batch(m.spinner.Tick, m.checkExistingConfigurations()) - } - } + if progressMsg.logOutput != "" { + m.installationLogs = append(m.installationLogs, progressMsg.logOutput) + m.installationLogs = tailLines(m.installationLogs, 50) + } + + if !progressMsg.isComplete { return m, m.listenForPackageProgress() } - return m, m.listenForLogs() + + if progressMsg.error != nil { + m.state = StateError + m.isLoading = false + return m, m.listenForPackageProgress() + } + + m.installationLogs = nil + m.state = StateConfigConfirmation + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.checkExistingConfigurations()) } func (m Model) updateInstallCompleteState(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "enter": - return m, tea.Quit - } + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "enter" { + return m, tea.Quit } return m, m.listenForLogs() } func (m Model) updateErrorState(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "enter": - return m, tea.Quit - } + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "enter" { + return m, tea.Quit } return m, m.listenForLogs() } @@ -390,7 +342,6 @@ func (m Model) listenForPackageProgress() tea.Cmd { if !ok { return packageProgressCompletedMsg{} } - // Always return the message, completion will be handled in updateInstallingPackagesState return msg } } @@ -398,30 +349,15 @@ func (m Model) listenForPackageProgress() tea.Cmd { func (m Model) viewDebugLogs() string { var b strings.Builder - theme := TerminalTheme() - - titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Primary)). - Bold(true) - - b.WriteString(titleStyle.Render("Debug Logs")) + b.WriteString(m.styles.Highlight.Render("Debug Logs")) b.WriteString("\n\n") - // Combine both logMessages and installationLogs - allLogs := append([]string{}, m.logMessages...) - allLogs = append(allLogs, m.installationLogs...) - + allLogs := append(append([]string{}, m.logMessages...), m.installationLogs...) if len(allLogs) == 0 { b.WriteString("No logs available\n") } else { - // Calculate available height (reserve space for header and footer) maxHeight := max(m.height-6, 10) - - // Show the most recent logs - startIdx := 0 - if len(allLogs) > maxHeight { - startIdx = len(allLogs) - maxHeight - } + startIdx := max(len(allLogs)-maxHeight, 0) for i := startIdx; i < len(allLogs); i++ { if allLogs[i] != "" { @@ -430,14 +366,11 @@ func (m Model) viewDebugLogs() string { } if startIdx > 0 { - subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle)) - b.WriteString(subtleStyle.Render(fmt.Sprintf("... (%d older log entries hidden)\n", startIdx))) + b.WriteString(m.styles.Subtle.Render(fmt.Sprintf("... (%d older log entries hidden)\n", startIdx))) } } - b.WriteString("\n") - statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Accent)) - b.WriteString(statusStyle.Render("Press Ctrl+D to return, Ctrl+C to quit")) - + b.WriteByte('\n') + b.WriteString(m.styles.Accent.Render("Press Ctrl+D to return, Ctrl+C to quit")) return b.String() } diff --git a/core/internal/tui/views_password.go b/core/internal/tui/views_password.go index 03d6c13b3..431c78b38 100644 --- a/core/internal/tui/views_password.go +++ b/core/internal/tui/views_password.go @@ -17,39 +17,16 @@ func (m Model) viewAuthMethodChoice() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Authentication Method") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Authentication Method")) + b.WriteString("\n\n") + b.WriteString(m.styles.Normal.Render("Fingerprint authentication is available.\nHow would you like to authenticate?")) b.WriteString("\n\n") - message := "Fingerprint authentication is available.\nHow would you like to authenticate?" - b.WriteString(m.styles.Normal.Render(message)) - b.WriteString("\n\n") - - // Option 0: Fingerprint - if m.selectedConfig == 0 { - option := m.styles.SelectedOption.Render("▶ Use Fingerprint") - b.WriteString(option) - } else { - option := m.styles.Normal.Render(" Use Fingerprint") - b.WriteString(option) - } - b.WriteString("\n") - - // Option 1: Password - if m.selectedConfig == 1 { - option := m.styles.SelectedOption.Render("▶ Use Password") - b.WriteString(option) - } else { - option := m.styles.Normal.Render(" Use Password") - b.WriteString(option) - } - b.WriteString("\n\n") - - help := m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back") - b.WriteString(help) + m.renderOptionList(&b, []listOption{{label: "Use Fingerprint"}, {label: "Use Password"}}, m.selectedAuthMethod) + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back")) return b.String() } @@ -57,28 +34,21 @@ func (m Model) viewFingerprintAuth() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Fingerprint Authentication") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Fingerprint Authentication")) b.WriteString("\n\n") if m.fingerprintFailed { - errorMsg := m.styles.Error.Render("✗ Fingerprint authentication failed") - b.WriteString(errorMsg) - b.WriteString("\n") - retryMsg := m.styles.Subtle.Render("Returning to authentication menu...") - b.WriteString(retryMsg) - } else { - message := "Please place your finger on the fingerprint reader." - b.WriteString(m.styles.Normal.Render(message)) - b.WriteString("\n\n") - - spinner := m.spinner.View() - status := m.styles.Normal.Render("Waiting for fingerprint...") - fmt.Fprintf(&b, "%s %s", spinner, status) + b.WriteString(m.styles.Error.Render("✗ Fingerprint authentication failed")) + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("Returning to authentication menu...")) + return b.String() } + b.WriteString(m.styles.Normal.Render("Please place your finger on the fingerprint reader.")) + b.WriteString("\n\n") + fmt.Fprintf(&b, "%s %s", m.spinner.View(), m.styles.Normal.Render("Waiting for fingerprint...")) + return b.String() } @@ -86,156 +56,102 @@ func (m Model) viewPasswordPrompt() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Password Authentication") - b.WriteString(title) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render("Password Authentication")) b.WriteString("\n\n") - - message := "Installation requires sudo privileges.\nPlease enter your password to continue:" - b.WriteString(m.styles.Normal.Render(message)) + b.WriteString(m.styles.Normal.Render("Installation requires sudo privileges.\nPlease enter your password to continue:")) b.WriteString("\n\n") - - // Password input b.WriteString(m.passwordInput.View()) - b.WriteString("\n") + b.WriteByte('\n') - // Show validation status - if m.packageProgress.step == "Validating sudo password..." { - spinner := m.spinner.View() - status := m.styles.Normal.Render(m.packageProgress.step) - b.WriteString(spinner + " " + status) - b.WriteString("\n") - } else if m.packageProgress.error != nil { - errorMsg := m.styles.Error.Render("✗ " + m.packageProgress.error.Error() + ". Please try again.") - b.WriteString(errorMsg) - b.WriteString("\n") - } else if m.packageProgress.step == "Password validation failed" { - errorMsg := m.styles.Error.Render("✗ Incorrect password. Please try again.") - b.WriteString(errorMsg) - b.WriteString("\n") + switch { + case m.authValidating: + fmt.Fprintf(&b, "%s %s\n", m.spinner.View(), m.styles.Normal.Render("Validating sudo password...")) + case m.authFailed: + b.WriteString(m.styles.Error.Render("✗ Incorrect password. Please try again.")) + b.WriteByte('\n') } - b.WriteString("\n") - help := m.styles.Subtle.Render("Enter: Continue, Esc: Back, Ctrl+C: Cancel") - b.WriteString(help) - + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("Enter: Continue, Esc: Back, Ctrl+C: Cancel")) return b.String() } func (m Model) updateAuthMethodChoiceState(msg tea.Msg) (tea.Model, tea.Cmd) { m.fingerprintFailed = false - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "up": - if m.selectedConfig > 0 { - m.selectedConfig-- - } - case "down": - if m.selectedConfig < 1 { - m.selectedConfig++ - } - case "enter": - if m.selectedConfig == 0 { - m.state = StateFingerprintAuth - m.isLoading = true - return m, tea.Batch(m.spinner.Tick, m.tryFingerprint()) - } else { - m.state = StatePasswordPrompt - m.passwordInput.Focus() - return m, nil - } - case "esc": - m.state = StateDependencyReview - return m, nil + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.String() { + case "up": + m.selectedAuthMethod = moveIndex(m.selectedAuthMethod, -1, 1) + case "down": + m.selectedAuthMethod = moveIndex(m.selectedAuthMethod, 1, 1) + case "enter": + if m.selectedAuthMethod == 0 { + m.state = StateFingerprintAuth + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.tryFingerprint()) } + return m.promptForPassword() + case "esc": + m.state = StateDependencyReview } return m, nil } func (m Model) updateFingerprintAuthState(msg tea.Msg) (tea.Model, tea.Cmd) { - if validMsg, ok := msg.(passwordValidMsg); ok { - if validMsg.valid { - m.sudoPassword = "" - m.packageProgress = packageInstallProgressMsg{} - m.state = StateInstallingPackages - m.isLoading = true - return m, tea.Batch(m.spinner.Tick, m.installPackages()) - } else { - m.fingerprintFailed = true - return m, m.delayThenReturn() + switch msg := msg.(type) { + case passwordValidMsg: + if msg.valid { + return m.startInstallation("") } - } + m.fingerprintFailed = true + return m, m.delayThenReturn() - if _, ok := msg.(delayCompleteMsg); ok { + case delayCompleteMsg: m.fingerprintFailed = false - m.selectedConfig = 0 + m.selectedAuthMethod = 0 m.state = StateAuthMethodChoice return m, nil } - return m, m.listenForLogs() } func (m Model) updatePasswordPromptState(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - if validMsg, ok := msg.(passwordValidMsg); ok { + m.authValidating = false if validMsg.valid { - // Password is valid, proceed with installation - m.sudoPassword = validMsg.password - m.passwordInput.SetValue("") // Clear password input - // Clear any error state - m.packageProgress = packageInstallProgressMsg{} - m.state = StateInstallingPackages - m.isLoading = true - return m, tea.Batch(m.spinner.Tick, m.installPackages()) - } else { - // Password is invalid, show error and stay on password prompt - m.packageProgress = packageInstallProgressMsg{ - progress: 0.0, - step: "Password validation failed", - error: fmt.Errorf("incorrect password"), - logOutput: "Authentication failed", - } - m.passwordInput.SetValue("") - m.passwordInput.Focus() - return m, nil + return m.startInstallation(validMsg.password) } + m.authFailed = true + m.passwordInput.SetValue("") + m.passwordInput.Focus() + return m, nil } if keyMsg, ok := msg.(tea.KeyMsg); ok { switch keyMsg.String() { case "enter": - // Don't allow multiple validation attempts while one is in progress - if m.packageProgress.step == "Validating sudo password..." { + if m.authValidating || m.passwordInput.Value() == "" { return m, nil } - - // Validate password first - password := m.passwordInput.Value() - if password == "" { - return m, nil // Don't proceed with empty password - } - - // Clear any previous error and show validation in progress - m.packageProgress = packageInstallProgressMsg{ - progress: 0.01, - step: "Validating sudo password...", - isComplete: false, - logOutput: "Testing password with sudo -v", - } - return m, m.validatePassword(password) + m.authValidating = true + m.authFailed = false + return m, m.validatePassword(m.passwordInput.Value()) case "esc": - // Go back to dependency review m.passwordInput.SetValue("") - m.packageProgress = packageInstallProgressMsg{} // Clear any validation state + m.authValidating = false + m.authFailed = false m.state = StateDependencyReview return m, nil } } + var cmd tea.Cmd m.passwordInput, cmd = m.passwordInput.Update(msg) return m, cmd } @@ -244,25 +160,20 @@ func checkFingerprintEnabled() bool { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - // Check if pam_fprintd.so is in PAM config - cmd := exec.CommandContext(ctx, "grep", "-q", "pam_fprintd.so", "/etc/pam.d/system-auth") - if err := cmd.Run(); err != nil { + pamCheck := exec.CommandContext(ctx, "grep", "-q", "pam_fprintd.so", "/etc/pam.d/system-auth") + if err := pamCheck.Run(); err != nil { return false } - // Check if fprintd-list exists and user has enrolled fingerprints user := os.Getenv("USER") if user == "" { return false } - listCmd := exec.CommandContext(ctx, "fprintd-list", user) - output, err := listCmd.CombinedOutput() + output, err := exec.CommandContext(ctx, "fprintd-list", user).CombinedOutput() if err != nil { return false } - - // If output contains "finger:" or similar, fingerprints are enrolled return strings.Contains(string(output), "finger") } @@ -273,16 +184,15 @@ func (m Model) delayThenReturn() tea.Cmd { } } +// Validates cached credentials without a password by pointing sudo at an +// askpass script that always fails, so only fingerprint/PAM auth can succeed. func (m Model) tryFingerprint() tea.Cmd { return func() tea.Msg { _ = privesc.ClearCache(context.Background()) - tmpDir := os.TempDir() - askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano())) - - scriptContent := "#!/bin/sh\nexit 1\n" - if err := os.WriteFile(askpassScript, []byte(scriptContent), 0o700); err != nil { - return passwordValidMsg{password: "", valid: false} + askpassScript := filepath.Join(os.TempDir(), fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano())) + if err := os.WriteFile(askpassScript, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { + return passwordValidMsg{valid: false} } defer os.Remove(askpassScript) @@ -290,9 +200,9 @@ func (m Model) tryFingerprint() tea.Cmd { defer cancel() if err := privesc.ValidateWithAskpass(ctx, askpassScript); err != nil { - return passwordValidMsg{password: "", valid: false} + return passwordValidMsg{valid: false} } - return passwordValidMsg{password: "", valid: true} + return passwordValidMsg{valid: true} } } @@ -302,7 +212,7 @@ func (m Model) validatePassword(password string) tea.Cmd { defer cancel() if err := privesc.ValidatePassword(ctx, password); err != nil { - return passwordValidMsg{password: "", valid: false} + return passwordValidMsg{valid: false} } return passwordValidMsg{password: password, valid: true} } diff --git a/core/internal/tui/views_privesc.go b/core/internal/tui/views_privesc.go index 51a23733f..432d3e3fd 100644 --- a/core/internal/tui/views_privesc.go +++ b/core/internal/tui/views_privesc.go @@ -12,24 +12,19 @@ func (m Model) viewSelectPrivesc() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") + b.WriteByte('\n') b.WriteString(m.styles.Title.Render("Privilege Escalation Tool")) b.WriteString("\n\n") b.WriteString(m.styles.Normal.Render("Multiple privilege tools are available. Choose one for installation:")) b.WriteString("\n\n") + options := make([]listOption, len(m.availablePrivesc)) for i, t := range m.availablePrivesc { - label := fmt.Sprintf("%s — %s", t.Name(), privescToolDescription(t)) - switch i { - case m.selectedPrivesc: - b.WriteString(m.styles.SelectedOption.Render("▶ " + label)) - default: - b.WriteString(m.styles.Normal.Render(" " + label)) - } - b.WriteString("\n") + options[i] = listOption{label: fmt.Sprintf("%s — %s", t.Name(), privescToolDescription(t))} } + m.renderOptionList(&b, options, m.selectedPrivesc) - b.WriteString("\n") + b.WriteByte('\n') b.WriteString(m.styles.Subtle.Render(fmt.Sprintf("Set %s= to skip this prompt in future runs.", privesc.EnvVar))) b.WriteString("\n\n") b.WriteString(m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back")) @@ -44,13 +39,9 @@ func (m Model) updateSelectPrivescState(msg tea.Msg) (tea.Model, tea.Cmd) { switch keyMsg.String() { case "up": - if m.selectedPrivesc > 0 { - m.selectedPrivesc-- - } + m.selectedPrivesc = moveIndex(m.selectedPrivesc, -1, len(m.availablePrivesc)-1) case "down": - if m.selectedPrivesc < len(m.availablePrivesc)-1 { - m.selectedPrivesc++ - } + m.selectedPrivesc = moveIndex(m.selectedPrivesc, 1, len(m.availablePrivesc)-1) case "enter": chosen := m.availablePrivesc[m.selectedPrivesc] if err := privesc.SetTool(chosen); err != nil { @@ -61,7 +52,6 @@ func (m Model) updateSelectPrivescState(msg tea.Msg) (tea.Model, tea.Cmd) { return m.routeToAuthAfterPrivesc() case "esc": m.state = StateDependencyReview - return m, nil } return m, nil } @@ -79,9 +69,8 @@ func privescToolDescription(t privesc.Tool) string { } } -// routeToAuthAfterPrivesc advances from the privesc-selection screen to the -// right auth flow. Sudo goes through the fingerprint/password path; doas and -// run0 skip password entry and proceed to install. +// Sudo goes through the fingerprint/password path; doas and run0 skip password +// entry and proceed straight to install. func (m Model) routeToAuthAfterPrivesc() (tea.Model, tea.Cmd) { tool, err := privesc.Detect() if err != nil { @@ -90,39 +79,27 @@ func (m Model) routeToAuthAfterPrivesc() (tea.Model, tea.Cmd) { return m, nil } - if tool == privesc.ToolSudo { - if checkFingerprintEnabled() { - m.state = StateAuthMethodChoice - m.selectedConfig = 0 - return m, nil - } - m.state = StatePasswordPrompt - m.passwordInput.Focus() - return m, nil + if tool != privesc.ToolSudo { + return m.startInstallation("") } - m.sudoPassword = "" - m.packageProgress = packageInstallProgressMsg{} - m.state = StateInstallingPackages - m.isLoading = true - return m, tea.Batch(m.spinner.Tick, m.installPackages()) + if checkFingerprintEnabled() { + m.selectedAuthMethod = 0 + m.state = StateAuthMethodChoice + return m, nil + } + return m.promptForPassword() } -// enterAuthPhase is called when dependency review (or the Gentoo screens) -// finish. It either routes directly to the sudo/fingerprint flow or shows -// the privesc-tool selection screen when multiple tools are available and -// no $DMS_PRIVESC override is set. func (m Model) enterAuthPhase() (tea.Model, tea.Cmd) { tools := privesc.AvailableTools() - _, envSet := privesc.EnvOverride() - if len(tools) == 0 { m.err = fmt.Errorf("no supported privilege tool (sudo/doas/run0) found on PATH") m.state = StateError return m, nil } - if envSet || len(tools) == 1 { + if _, envSet := privesc.EnvOverride(); envSet || len(tools) == 1 { return m.routeToAuthAfterPrivesc() } diff --git a/core/internal/tui/views_selection.go b/core/internal/tui/views_selection.go index 4c1bf5680..2a165444c 100644 --- a/core/internal/tui/views_selection.go +++ b/core/internal/tui/views_selection.go @@ -10,192 +10,153 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// windowManagerOptions returns the WM enums in selection-list order (debian omits -// Hyprland). selectedWM indexes into this, so all index->WM conversions use it. -func (m Model) windowManagerOptions() []deps.WindowManager { - opts := []deps.WindowManager{deps.WindowManagerNiri} - if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" { - opts = append(opts, deps.WindowManagerHyprland) - } - opts = append(opts, deps.WindowManagerMango) - return opts +type listOption struct { + label string + desc string } -// selectedWindowManager maps the current selectedWM index to its WM enum. -func (m Model) selectedWindowManager() deps.WindowManager { - opts := m.windowManagerOptions() - if m.selectedWM >= 0 && m.selectedWM < len(opts) { - return opts[m.selectedWM] +func (m Model) isGentoo() bool { + return m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" +} + +type wmChoice struct { + wm deps.WindowManager + label string + desc string +} + +func (m Model) windowManagerChoices() []wmChoice { + choices := []wmChoice{ + {deps.WindowManagerNiri, "niri", "Scrollable-tiling Wayland compositor."}, } - return deps.WindowManagerNiri + if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" { + choices = append(choices, wmChoice{deps.WindowManagerHyprland, "Hyprland", "Dynamic tiling Wayland compositor."}) + } + return append(choices, wmChoice{deps.WindowManagerMango, "mango", "dwl-based dynamic tiling Wayland compositor."}) +} + +func (m Model) chosenWindowManager() deps.WindowManager { + choices := m.windowManagerChoices() + if m.selectedWM < 0 || m.selectedWM >= len(choices) { + return deps.WindowManagerNiri + } + return choices[m.selectedWM].wm +} + +type terminalChoice struct { + terminal deps.Terminal + label string + desc string +} + +func (m Model) terminalChoices() []terminalChoice { + var choices []terminalChoice + if !m.isGentoo() { + choices = append(choices, terminalChoice{deps.TerminalGhostty, "ghostty", "A fast, native terminal emulator built in Zig."}) + } + return append(choices, + terminalChoice{deps.TerminalKitty, "kitty", "A feature-rich, customizable terminal emulator."}, + terminalChoice{deps.TerminalAlacritty, "alacritty", "A simple terminal emulator."}, + ) +} + +func (m Model) chosenTerminal() deps.Terminal { + choices := m.terminalChoices() + if m.selectedTerminal < 0 || m.selectedTerminal >= len(choices) { + return choices[0].terminal + } + return choices[m.selectedTerminal].terminal +} + +func (m Model) renderOptionList(b *strings.Builder, options []listOption, selected int) { + for i, opt := range options { + cursor, style := " ", m.styles.Normal + if i == selected { + cursor, style = "▶ ", m.styles.SelectedOption + } + b.WriteString(style.Render(cursor + opt.label)) + b.WriteByte('\n') + + if opt.desc == "" { + continue + } + b.WriteString(m.styles.Subtle.Render(" " + opt.desc)) + b.WriteByte('\n') + if i < len(options)-1 { + b.WriteByte('\n') + } + } +} + +func (m Model) viewChoiceScreen(title string, options []listOption, selected int) string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteByte('\n') + b.WriteString(m.styles.Title.Render(title)) + b.WriteString("\n\n") + + m.renderOptionList(&b, options, selected) + + b.WriteByte('\n') + b.WriteString(m.styles.Subtle.Render("Use ↑/↓ to navigate, Enter to select, Esc to go back")) + return b.String() } func (m Model) viewSelectWindowManager() string { - var b strings.Builder - - b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Choose Window Manager") - b.WriteString(title) - b.WriteString("\n\n") - - options := []struct { - name string - description string - }{ - {"niri", "Scrollable-tiling Wayland compositor."}, + choices := m.windowManagerChoices() + options := make([]listOption, len(choices)) + for i, c := range choices { + options[i] = listOption{c.label, c.desc} } - - if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" { - options = append(options, struct { - name string - description string - }{"Hyprland", "Dynamic tiling Wayland compositor."}) - } - - options = append(options, struct { - name string - description string - }{"mango", "dwl-based dynamic tiling Wayland compositor."}) - - for i, option := range options { - if i == m.selectedWM { - selected := m.styles.SelectedOption.Render("▶ " + option.name) - b.WriteString(selected) - b.WriteString("\n") - desc := m.styles.Subtle.Render(" " + option.description) - b.WriteString(desc) - } else { - normal := m.styles.Normal.Render(" " + option.name) - b.WriteString(normal) - b.WriteString("\n") - desc := m.styles.Subtle.Render(" " + option.description) - b.WriteString(desc) - } - b.WriteString("\n") - if i < len(options)-1 { - b.WriteString("\n") - } - } - - b.WriteString("\n") - help := m.styles.Subtle.Render("Use ↑/↓ to navigate, Enter to select, Esc to go back") - b.WriteString(help) - - return b.String() + return m.viewChoiceScreen("Choose Window Manager", options, m.selectedWM) } func (m Model) viewSelectTerminal() string { - var b strings.Builder - - b.WriteString(m.renderBanner()) - b.WriteString("\n") - - title := m.styles.Title.Render("Choose Terminal Emulator") - b.WriteString(title) - b.WriteString("\n\n") - - var options []struct { - name string - description string + choices := m.terminalChoices() + options := make([]listOption, len(choices)) + for i, c := range choices { + options[i] = listOption{c.label, c.desc} } - - if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { - options = []struct { - name string - description string - }{ - {"kitty", "A feature-rich, customizable terminal emulator."}, - {"alacritty", "A simple terminal emulator."}, - } - } else { - options = []struct { - name string - description string - }{ - {"ghostty", "A fast, native terminal emulator built in Zig."}, - {"kitty", "A feature-rich, customizable terminal emulator."}, - {"alacritty", "A simple terminal emulator."}, - } - } - - for i, option := range options { - if i == m.selectedTerminal { - selected := m.styles.SelectedOption.Render("▶ " + option.name) - b.WriteString(selected) - b.WriteString("\n") - desc := m.styles.Subtle.Render(" " + option.description) - b.WriteString(desc) - } else { - normal := m.styles.Normal.Render(" " + option.name) - b.WriteString(normal) - b.WriteString("\n") - desc := m.styles.Subtle.Render(" " + option.description) - b.WriteString(desc) - } - b.WriteString("\n") - if i < len(options)-1 { - b.WriteString("\n") - } - } - - b.WriteString("\n") - help := m.styles.Subtle.Render("Use ↑/↓ to navigate, Enter to select, Esc to go back") - b.WriteString(help) - - return b.String() + return m.viewChoiceScreen("Choose Terminal Emulator", options, m.selectedTerminal) } -func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - maxTerminalIndex := 2 - if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { - maxTerminalIndex = 1 - } +func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, m.listenForLogs() + } - switch keyMsg.String() { - case "up": - if m.selectedTerminal > 0 { - m.selectedTerminal-- - } - case "down": - if m.selectedTerminal < maxTerminalIndex { - m.selectedTerminal++ - } - case "enter": - m.state = StateDetectingDeps - m.isLoading = true - return m, tea.Batch(m.spinner.Tick, m.detectDependencies()) - case "esc": - m.state = StateSelectWindowManager - return m, m.listenForLogs() - } + switch keyMsg.String() { + case "up": + m.selectedWM = moveIndex(m.selectedWM, -1, len(m.windowManagerChoices())-1) + case "down": + m.selectedWM = moveIndex(m.selectedWM, 1, len(m.windowManagerChoices())-1) + case "enter": + m.state = StateSelectTerminal + case "esc": + m.state = StateWelcome } return m, m.listenForLogs() } -func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - maxWMIndex := len(m.windowManagerOptions()) - 1 +func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, m.listenForLogs() + } - switch keyMsg.String() { - case "up": - if m.selectedWM > 0 { - m.selectedWM-- - } - case "down": - if m.selectedWM < maxWMIndex { - m.selectedWM++ - } - case "enter": - m.state = StateSelectTerminal - return m, m.listenForLogs() - case "esc": - // Go back to welcome screen - m.state = StateWelcome - return m, m.listenForLogs() - } + switch keyMsg.String() { + case "up": + m.selectedTerminal = moveIndex(m.selectedTerminal, -1, len(m.terminalChoices())-1) + case "down": + m.selectedTerminal = moveIndex(m.selectedTerminal, 1, len(m.terminalChoices())-1) + case "enter": + m.state = StateDetectingDeps + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.detectDependencies()) + case "esc": + m.state = StateSelectWindowManager } return m, m.listenForLogs() } @@ -203,39 +164,15 @@ func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) func (m Model) detectDependencies() tea.Cmd { return func() tea.Msg { if m.osInfo == nil { - return depsDetectedMsg{deps: nil, err: fmt.Errorf("OS info not available")} + return depsDetectedMsg{err: fmt.Errorf("OS info not available")} } detector, err := distros.NewDependencyDetector(m.osInfo.Distribution.ID, m.logChan) if err != nil { - return depsDetectedMsg{deps: nil, err: err} + return depsDetectedMsg{err: err} } - // Convert TUI selection to deps enum - wm := m.selectedWindowManager() - - var terminal deps.Terminal - if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { - switch m.selectedTerminal { - case 0: - terminal = deps.TerminalKitty - case 1: - terminal = deps.TerminalAlacritty - default: - terminal = deps.TerminalKitty - } - } else { - switch m.selectedTerminal { - case 0: - terminal = deps.TerminalGhostty - case 1: - terminal = deps.TerminalKitty - default: - terminal = deps.TerminalAlacritty - } - } - - dependencies, err := detector.DetectDependenciesWithTerminal(context.Background(), wm, terminal) + dependencies, err := detector.DetectDependenciesWithTerminal(context.Background(), m.chosenWindowManager(), m.chosenTerminal()) return depsDetectedMsg{deps: dependencies, err: err} } } diff --git a/core/internal/tui/views_welcome.go b/core/internal/tui/views_welcome.go index c5ae862cc..902692db2 100644 --- a/core/internal/tui/views_welcome.go +++ b/core/internal/tui/views_welcome.go @@ -13,204 +13,129 @@ func (m Model) viewWelcome() string { var b strings.Builder b.WriteString(m.renderBanner()) - b.WriteString("\n") - - theme := TerminalTheme() - - decorator := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Accent)). - Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - - titleBox := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color(theme.Primary)). - Padding(0, 2). - MarginBottom(1) - - titleText := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Primary)). - Bold(true). - Render("dankinstall") - - versionTag := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Accent)). - Italic(true). - Render(" // Dank Linux Installer") - - subtitle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Subtle)). - Italic(true). - Render("Quickstart for a Dank Desktop") - - b.WriteString(decorator) - b.WriteString("\n") - b.WriteString(titleBox.Render(titleText + versionTag)) - b.WriteString("\n") - b.WriteString(subtitle) + b.WriteByte('\n') + b.WriteString(m.styles.Accent.Render(strings.Repeat("━", 58))) + b.WriteByte('\n') + b.WriteString(m.styles.TitleBox.Render( + m.styles.Highlight.Render("dankinstall") + m.styles.AccentItalic.Render(" // Dank Linux Installer"))) + b.WriteByte('\n') + b.WriteString(m.styles.SubtleItalic.Render("Quickstart for a Dank Desktop")) b.WriteString("\n\n") - if m.osInfo != nil { - if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { - errorBox := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FF6B6B")). - Padding(1, 2). - MarginBottom(1) - - errorTitle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B6B")). - Bold(true). - Render("⚠ UNSUPPORTED DISTRIBUTION") - - var errorMsg string - switch m.osInfo.Distribution.ID { - case "ubuntu": - errorMsg = fmt.Sprintf("Ubuntu %s is not supported.\n\nOnly Ubuntu 25.04+ is supported.\n\nPlease upgrade to Ubuntu 25.04 or later.", m.osInfo.VersionID) - case "debian": - errorMsg = fmt.Sprintf("Debian %s is not supported.\n\nOnly Debian 13+ (Trixie) is supported.\n\nPlease upgrade to Debian 13 or later.", m.osInfo.VersionID) - case "nixos": - errorMsg = "See the NixOS documentation for installation instructions: https://danklinux.com/docs/dankmaterialshell/nixos." - default: - errorMsg = fmt.Sprintf("%s is not supported.\nFeel free to request on https://github.com/AvengeMedia/DankMaterialShell", m.osInfo.PrettyName) - } - - errorMsgStyled := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Text)). - Render(errorMsg) - - b.WriteString(errorBox.Render(errorTitle + "\n\n" + errorMsgStyled)) - b.WriteString("\n\n") - } else { - // System info box - sysBox := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(theme.Subtle)). - Padding(0, 1). - MarginBottom(1) - - // Style the distro name with its color - distroStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(m.osInfo.Distribution.HexColorCode)). - Bold(true) - distroName := distroStyle.Render(m.osInfo.PrettyName) - - archStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Accent)) - - sysInfo := fmt.Sprintf("System: %s / %s", distroName, archStyle.Render(m.osInfo.Architecture)) - b.WriteString(sysBox.Render(sysInfo)) - b.WriteString("\n") - - // Feature list with better styling - featTitle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Primary)). - Bold(true). - Underline(true). - Render("WHAT YOU GET") - b.WriteString(featTitle + "\n\n") - - features := []string{ - "[shell] dms (DankMaterialShell)", - "[wm] niri or Hyprland", - "[term] Ghostty, kitty, or Alacritty", - "[style] All the themes, automatically.", - "[config] DANK defaults - keybindings, rules, animations, etc.", - } - - for i, feat := range features { - prefix := feat[:9] - content := feat[10:] - - prefixStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Accent)). - Bold(true) - - contentStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Text)) - - if i == len(features)-1 { - contentStyle = contentStyle.Bold(true) - } - - fmt.Fprintf(&b, " %s %s\n", - prefixStyle.Render(prefix), - contentStyle.Render(content)) - } - - b.WriteString("\n") - - noteStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Subtle)). - Italic(true) - note := noteStyle.Render("* Existing configs can be replaced (and backed up) or preserved") - b.WriteString(note) - b.WriteString("\n") - - if m.osInfo.Distribution.ID == "gentoo" { - gentooNote := noteStyle.Render("* Will set per-package USE flags and unmask testing packages as needed") - b.WriteString(gentooNote) - b.WriteString("\n") - } - - b.WriteString("\n") - } - - } else if m.isLoading { - spinner := m.spinner.View() - loading := m.styles.Normal.Render("Detecting system...") - fmt.Fprintf(&b, "%s %s\n\n", spinner, loading) + switch { + case m.osInfo == nil && m.isLoading: + fmt.Fprintf(&b, "%s %s\n\n", m.spinner.View(), m.styles.Normal.Render("Detecting system...")) + case m.osInfo != nil && distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID): + m.writeUnsupportedDistro(&b) + case m.osInfo != nil: + m.writeSystemSummary(&b) } - // Footer with better visual separation - footerDivider := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Subtle)). - Render("───────────────────────────────────────────────────────────") - b.WriteString(footerDivider + "\n") - - if m.osInfo != nil { - ctrlKey := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Primary)). - Bold(true). - Render("Ctrl+C") - - if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { - b.WriteString(m.styles.Subtle.Render("Press ") + ctrlKey + m.styles.Subtle.Render(" to quit")) - } else { - enterKey := lipgloss.NewStyle(). - Foreground(lipgloss.Color(theme.Primary)). - Bold(true). - Render("Enter") - - b.WriteString(m.styles.Subtle.Render("Press ") + enterKey + m.styles.Subtle.Render(" to choose window manager, ") + ctrlKey + m.styles.Subtle.Render(" to quit")) - } - } else { - help := m.styles.Subtle.Render("Press Enter to continue, Ctrl+C to quit") - b.WriteString(help) - } + b.WriteString(m.styles.Subtle.Render(strings.Repeat("─", 59))) + b.WriteByte('\n') + m.writeWelcomeFooter(&b) return b.String() } -func (m Model) updateWelcomeState(msg tea.Msg) (tea.Model, tea.Cmd) { - if completeMsg, ok := msg.(osInfoCompleteMsg); ok { - m.isLoading = false - if completeMsg.err != nil { - m.err = completeMsg.err - m.state = StateError - } else { - m.osInfo = completeMsg.info - } - return m, m.listenForLogs() +func (m Model) writeUnsupportedDistro(b *strings.Builder) { + title := m.styles.Error.Bold(true).Render("⚠ UNSUPPORTED DISTRIBUTION") + msg := m.styles.Normal.Render(unsupportedDistroMessage(m.osInfo)) + b.WriteString(m.styles.ErrorBox.Render(title + "\n\n" + msg)) + b.WriteString("\n\n") +} + +func unsupportedDistroMessage(info *distros.OSInfo) string { + switch info.Distribution.ID { + case "ubuntu": + return fmt.Sprintf("Ubuntu %s is not supported.\n\nOnly Ubuntu 25.04+ is supported.\n\nPlease upgrade to Ubuntu 25.04 or later.", info.VersionID) + case "debian": + return fmt.Sprintf("Debian %s is not supported.\n\nOnly Debian 13+ (Trixie) is supported.\n\nPlease upgrade to Debian 13 or later.", info.VersionID) + case "nixos": + return "See the NixOS documentation for installation instructions: https://danklinux.com/docs/dankmaterialshell/nixos." + default: + return fmt.Sprintf("%s is not supported.\nFeel free to request on https://github.com/AvengeMedia/DankMaterialShell", info.PrettyName) + } +} + +func (m Model) writeSystemSummary(b *strings.Builder) { + distroName := lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.osInfo.Distribution.HexColorCode)). + Bold(true). + Render(m.osInfo.PrettyName) + arch := m.styles.Accent.Render(m.osInfo.Architecture) + b.WriteString(m.styles.InfoBox.Render(fmt.Sprintf("System: %s / %s", distroName, arch))) + b.WriteByte('\n') + + b.WriteString(m.styles.Highlight.Underline(true).Render("WHAT YOU GET")) + b.WriteString("\n\n") + + features := []struct { + tag string + desc string + }{ + {"[shell]", "dms (DankMaterialShell)"}, + {"[wm]", "niri or Hyprland"}, + {"[term]", "Ghostty, kitty, or Alacritty"}, + {"[style]", "All the themes, automatically."}, + {"[config]", "DANK defaults - keybindings, rules, animations, etc."}, } - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "enter": - if m.osInfo != nil && !distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { - m.state = StateSelectWindowManager - return m, m.listenForLogs() - } + tagStyle := m.styles.Accent.Bold(true) + for i, feat := range features { + descStyle := m.styles.Normal + if i == len(features)-1 { + descStyle = descStyle.Bold(true) } + fmt.Fprintf(b, " %s %s\n", tagStyle.Render(fmt.Sprintf("%-9s", feat.tag)), descStyle.Render(feat.desc)) + } + b.WriteByte('\n') + + b.WriteString(m.styles.SubtleItalic.Render("* Existing configs can be replaced (and backed up) or preserved")) + b.WriteByte('\n') + if m.isGentoo() { + b.WriteString(m.styles.SubtleItalic.Render("* Will set per-package USE flags and unmask testing packages as needed")) + b.WriteByte('\n') + } + b.WriteByte('\n') +} + +func (m Model) writeWelcomeFooter(b *strings.Builder) { + if m.osInfo == nil { + b.WriteString(m.styles.Subtle.Render("Press Enter to continue, Ctrl+C to quit")) + return + } + + ctrlKey := m.styles.Highlight.Render("Ctrl+C") + if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { + b.WriteString(m.styles.Subtle.Render("Press ") + ctrlKey + m.styles.Subtle.Render(" to quit")) + return + } + + enterKey := m.styles.Highlight.Render("Enter") + b.WriteString(m.styles.Subtle.Render("Press ") + enterKey + + m.styles.Subtle.Render(" to choose window manager, ") + ctrlKey + m.styles.Subtle.Render(" to quit")) +} + +func (m Model) updateWelcomeState(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case osInfoCompleteMsg: + m.isLoading = false + if msg.err != nil { + m.err = msg.err + m.state = StateError + return m, m.listenForLogs() + } + m.osInfo = msg.info + + case tea.KeyMsg: + if msg.String() != "enter" { + break + } + if m.osInfo == nil || distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { + break + } + m.state = StateSelectWindowManager } return m, m.listenForLogs() }