dankinstall: refactor tui package, single-source selection state

This commit is contained in:
bbedward
2026-08-30 21:13:53 -04:00
parent 4cdfc53dcd
commit ac46c7dd1b
17 changed files with 1227 additions and 1558 deletions
+3 -16
View File
@@ -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{})
-1
View File
@@ -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
-2
View File
@@ -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=
+58 -50
View File
@@ -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
}
+204
View File
@@ -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)
}
}
+1 -9
View File
@@ -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)
}
-1
View File
@@ -18,6 +18,5 @@ const (
StateConfigConfirmation
StateDeployingConfigs
StateInstallComplete
StateFinalComplete
StateError
)
+72 -43
View File
@@ -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
}
+135 -301
View File
@@ -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()}}
}
}
+214 -243
View File
@@ -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,
}
}
+16 -38
View File
@@ -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}
}
}
+12 -24
View File
@@ -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()
+172 -239
View File
@@ -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()
}
+79 -169
View File
@@ -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}
}
+18 -41
View File
@@ -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=<tool> 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()
}
+131 -194
View File
@@ -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}
}
}
+112 -187
View File
@@ -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()
}