1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-11 07:52:50 -05:00

rename backend to core

This commit is contained in:
bbedward
2025-11-12 23:12:31 -05:00
parent 0fdc0748cf
commit db584b7897
280 changed files with 265 additions and 265 deletions

244
core/internal/tui/app.go Normal file
View File

@@ -0,0 +1,244 @@
package tui
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type Model struct {
version string
state ApplicationState
osInfo *distros.OSInfo
dependencies []deps.Dependency
err error
spinner spinner.Model
passwordInput textinput.Model
width int
height int
isLoading bool
styles Styles
logMessages []string
logChan chan string
logFilePath string
packageProgressChan chan packageInstallProgressMsg
packageProgress packageInstallProgressMsg
installationLogs []string
showDebugLogs bool
selectedWM int
selectedTerminal int
selectedDep int
selectedConfig int
reinstallItems map[string]bool
disabledItems map[string]bool
replaceConfigs map[string]bool
skipGentooUseFlags bool
sudoPassword string
existingConfigs []ExistingConfigInfo
fingerprintFailed bool
}
func NewModel(version string, logFilePath string) Model {
s := spinner.New()
s.Spinner = spinner.Dot
theme := TerminalTheme()
styles := NewStyles(theme)
s.Style = styles.SpinnerStyle
pi := textinput.New()
pi.Placeholder = "Enter sudo password"
pi.EchoMode = textinput.EchoPassword
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,
logFilePath: logFilePath,
packageProgressChan: packageProgressChan,
packageProgress: packageInstallProgressMsg{
progress: 0.0,
step: "Initializing package installation",
isComplete: false,
},
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{},
}
}
func (m Model) GetLogChan() <-chan string {
return m.logChan
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
m.listenForLogs(),
m.detectOS(),
)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.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 {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(tickMsg)
return m, tea.Batch(cmd, m.listenForLogs())
}
if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
m.width = sizeMsg.Width
m.height = sizeMsg.Height
}
if logMsg, ok := msg.(logMsg); ok {
m.logMessages = append(m.logMessages, logMsg.message)
return m, m.listenForLogs()
}
switch m.state {
case StateWelcome:
return m.updateWelcomeState(msg)
case StateSelectWindowManager:
return m.updateSelectWindowManagerState(msg)
case StateSelectTerminal:
return m.updateSelectTerminalState(msg)
case StateMissingWMInstructions:
return m.updateMissingWMInstructionsState(msg)
case StateDetectingDeps:
return m.updateDetectingDepsState(msg)
case StateDependencyReview:
return m.updateDependencyReviewState(msg)
case StateGentooUseFlags:
return m.updateGentooUseFlagsState(msg)
case StateGentooGCCCheck:
return m.updateGentooGCCCheckState(msg)
case StateAuthMethodChoice:
return m.updateAuthMethodChoiceState(msg)
case StateFingerprintAuth:
return m.updateFingerprintAuthState(msg)
case StatePasswordPrompt:
return m.updatePasswordPromptState(msg)
case StateInstallingPackages:
return m.updateInstallingPackagesState(msg)
case StateConfigConfirmation:
return m.updateConfigConfirmationState(msg)
case StateDeployingConfigs:
return m.updateDeployingConfigsState(msg)
case StateInstallComplete:
return m.updateInstallCompleteState(msg)
case StateError:
return m.updateErrorState(msg)
default:
return m, m.listenForLogs()
}
}
func (m Model) View() string {
// If debug logs are shown, show that view regardless of state
if m.showDebugLogs {
return m.viewDebugLogs()
}
switch m.state {
case StateWelcome:
return m.viewWelcome()
case StateSelectWindowManager:
return m.viewSelectWindowManager()
case StateSelectTerminal:
return m.viewSelectTerminal()
case StateMissingWMInstructions:
return m.viewMissingWMInstructions()
case StateDetectingDeps:
return m.viewDetectingDeps()
case StateDependencyReview:
return m.viewDependencyReview()
case StateGentooUseFlags:
return m.viewGentooUseFlags()
case StateGentooGCCCheck:
return m.viewGentooGCCCheck()
case StateAuthMethodChoice:
return m.viewAuthMethodChoice()
case StateFingerprintAuth:
return m.viewFingerprintAuth()
case StatePasswordPrompt:
return m.viewPasswordPrompt()
case StateInstallingPackages:
return m.viewInstallingPackages()
case StateConfigConfirmation:
return m.viewConfigConfirmation()
case StateDeployingConfigs:
return m.viewDeployingConfigs()
case StateInstallComplete:
return m.viewInstallComplete()
case StateError:
return m.viewError()
default:
return m.viewWelcome()
}
}
func (m Model) listenForLogs() tea.Cmd {
return func() tea.Msg {
select {
case msg, ok := <-m.logChan:
if !ok {
return nil
}
return logMsg{message: msg}
default:
return nil
}
}
}
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
}
return osInfoCompleteMsg{info: osInfoMsg, err: err}
}
}

View File

@@ -0,0 +1,21 @@
package tui
import "github.com/charmbracelet/lipgloss"
func (m Model) renderBanner() string {
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ `
theme := TerminalTheme()
style := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true).
MarginBottom(1)
return style.Render(logo)
}

View File

@@ -0,0 +1,39 @@
package tui
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
)
type logMsg struct {
message string
}
type osInfoCompleteMsg struct {
info *distros.OSInfo
err error
}
type depsDetectedMsg struct {
deps []deps.Dependency
err error
}
type packageInstallProgressMsg struct {
progress float64
step string
isComplete bool
needsSudo bool
commandInfo string
logOutput string
error error
}
type packageProgressCompletedMsg struct{}
type passwordValidMsg struct {
password string
valid bool
}
type delayCompleteMsg struct{}

View File

@@ -0,0 +1,23 @@
package tui
type ApplicationState int
const (
StateWelcome ApplicationState = iota
StateSelectWindowManager
StateSelectTerminal
StateMissingWMInstructions
StateDetectingDeps
StateDependencyReview
StateGentooUseFlags
StateGentooGCCCheck
StateAuthMethodChoice
StateFingerprintAuth
StatePasswordPrompt
StateInstallingPackages
StateConfigConfirmation
StateDeployingConfigs
StateInstallComplete
StateFinalComplete
StateError
)

124
core/internal/tui/styles.go Normal file
View File

@@ -0,0 +1,124 @@
package tui
import (
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss"
)
type AppTheme struct {
Primary string
Secondary string
Accent string
Text string
Subtle string
Error string
Warning string
Success string
Background string
Surface string
}
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
}
}
func NewStyles(theme AppTheme) Styles {
return Styles{
Title: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true).
MarginLeft(1).
MarginBottom(1),
Normal: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Text)),
Bold: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Text)).
Bold(true),
Subtle: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Subtle)),
Error: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Error)),
Warning: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Warning)),
StatusBar: lipgloss.NewStyle().
Foreground(lipgloss.Color("#33275e")).
Background(lipgloss.Color(theme.Primary)).
Padding(0, 1),
Key: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Accent)).
Bold(true),
SpinnerStyle: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)),
Success: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Success)).
Bold(true),
HighlightButton: lipgloss.NewStyle().
Foreground(lipgloss.Color("#33275e")).
Background(lipgloss.Color(theme.Primary)).
Padding(0, 2).
Bold(true),
SelectedOption: lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Accent)).
Bold(true),
CodeBlock: lipgloss.NewStyle().
Background(lipgloss.Color(theme.Surface)).
Foreground(lipgloss.Color(theme.Text)).
Padding(1, 2).
MarginLeft(2),
}
}
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
}

View File

@@ -0,0 +1,382 @@
package tui
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
tea "github.com/charmbracelet/bubbletea"
)
type configDeploymentResult struct {
results []config.DeploymentResult
error error
}
type ExistingConfigInfo struct {
ConfigType string
Path string
Exists bool
}
type configCheckResult struct {
configs []ExistingConfigInfo
error error
}
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.WriteString("\n\n")
spinner := m.spinner.View()
status := m.styles.Normal.Render("Setting up configuration files...")
b.WriteString(fmt.Sprintf("%s %s", spinner, status))
b.WriteString("\n\n")
// 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")
}
}
}
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
}
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
m.isLoading = false
return m, nil
}
return m, m.listenForLogs()
}
func (m Model) deployConfigurations() tea.Cmd {
return func() tea.Msg {
// Determine the selected window manager
var wm deps.WindowManager
switch m.selectedWM {
case 0:
wm = deps.WindowManagerNiri
case 1:
wm = deps.WindowManagerHyprland
default:
wm = deps.WindowManagerNiri
}
// 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.DeployConfigurationsSelectiveWithReinstalls(context.Background(), wm, terminal, m.dependencies, m.replaceConfigs, m.reinstallItems)
return configDeploymentResult{
results: results,
error: err,
}
}
}
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.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)
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")
}
}
backup := m.styles.Success.Render("✓ Replaced configurations will be backed up with timestamp")
b.WriteString(backup)
b.WriteString("\n\n")
help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle replace/keep, Enter: Continue")
b.WriteString(help)
return b.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
}
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 "up":
if m.selectedConfig > 0 {
for i := m.selectedConfig - 1; i >= 0; i-- {
if m.existingConfigs[i].Exists {
m.selectedConfig = i
break
}
}
}
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
}
}
}
case " ":
if len(m.existingConfigs) > 0 && m.selectedConfig < len(m.existingConfigs) {
configType := m.existingConfigs[m.selectedConfig].ConfigType
if m.existingConfigs[m.selectedConfig].Exists {
m.replaceConfigs[configType] = !m.replaceConfigs[configType]
}
}
case "enter":
m.state = StateDeployingConfigs
return m, m.deployConfigurations()
}
}
return m, nil
}
func (m Model) checkExistingConfigurations() tea.Cmd {
return func() tea.Msg {
var configs []ExistingConfigInfo
if m.selectedWM == 0 {
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,
})
} else {
hyprlandPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandExists := false
if _, err := os.Stat(hyprlandPath); err == nil {
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,
}
}
}

View File

@@ -0,0 +1,256 @@
package tui
import (
"context"
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea"
)
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.WriteString("\n\n")
spinner := m.spinner.View()
status := m.styles.Normal.Render("Scanning system for existing packages and configurations...")
b.WriteString(fmt.Sprintf("%s %s", spinner, status))
return b.String()
}
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.WriteString("\n\n")
if len(m.dependencies) > 0 {
for i, dep := range m.dependencies {
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")
}
}
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)
} 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)
}
b.WriteString(line)
b.WriteString("\n")
}
}
b.WriteString("\n")
help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle, G: Toggle stable/git, Enter: Continue")
b.WriteString(help)
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 = depsMsg.deps
m.state = StateDependencyReview
}
return m, m.listenForLogs()
}
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)"
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
}
}
}
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
}
}
// Check if fingerprint is enabled
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0 // Default to fingerprint
return m, nil
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
return m, nil
}
case "esc":
m.state = StateSelectWindowManager
return m, nil
}
}
return m, m.listenForLogs()
}
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,
}
}
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,
}
}
// Convert TUI selection to deps enum
var wm deps.WindowManager
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri
} else {
wm = deps.WindowManagerHyprland
}
installerProgressChan := 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)
if err != nil {
installerProgressChan <- distros.InstallProgressMsg{
Progress: 0.0,
Step: fmt.Sprintf("Installation error: %s", err.Error()),
IsComplete: true,
Error: err,
}
}
}()
// Convert installer messages to TUI messages
go func() {
for msg := range installerProgressChan {
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"
}()
return packageInstallProgressMsg{
progress: 0.05,
step: "Starting installation...",
isComplete: false,
}
}
}

View File

@@ -0,0 +1,103 @@
package tui
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type gccVersionCheckMsg struct {
version string
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.WriteString("\n\n")
error := m.styles.Error.Render("⚠ Hyprland requires GCC 15 or newer")
b.WriteString(error)
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("\n\n")
instructionsTitle := m.styles.Subtle.Render("To upgrade GCC:")
b.WriteString(instructionsTitle)
b.WriteString("\n\n")
steps := []string{
"1. Install GCC 15 (if not already installed):",
" sudo emerge -1av =sys-devel/gcc:15",
"",
"2. Switch to GCC 15 using gcc-config:",
" sudo gcc-config $(gcc-config -l | grep gcc-15 | awk '{print $2}' | head -1)",
"",
"3. Update environment:",
" source /etc/profile",
"",
"4. Restart this installer",
}
for _, step := range steps {
stepLine := m.styles.Subtle.Render(step)
b.WriteString(stepLine)
b.WriteString("\n")
}
b.WriteString("\n")
help := m.styles.Subtle.Render("Press Esc to go back, Ctrl+C to exit")
b.WriteString(help)
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
}
}
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()
if err != nil {
return gccVersionCheckMsg{err: err}
}
version := strings.TrimSpace(string(output))
parts := strings.Split(version, ".")
if len(parts) == 0 {
return gccVersionCheckMsg{err: fmt.Errorf("invalid gcc version format")}
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return gccVersionCheckMsg{err: err}
}
return gccVersionCheckMsg{
version: version,
major: major,
err: nil,
}
}
}

View File

@@ -0,0 +1,92 @@
package tui
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea"
)
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.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("\n\n")
for _, flag := range distros.GentooGlobalUseFlags {
flagLine := m.styles.Success.Render(fmt.Sprintf(" • %s", flag))
b.WriteString(flagLine)
b.WriteString("\n")
}
b.WriteString("\n")
note := m.styles.Subtle.Render("These flags ensure proper Qt6, Wayland, and compositor support.")
b.WriteString(note)
b.WriteString("\n\n")
var toggleLine string
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)
}
b.WriteString(toggleLine)
b.WriteString("\n\n")
help := m.styles.Subtle.Render("Space: Toggle skip, Enter: Continue, Esc: Go back")
b.WriteString(help)
return b.String()
}
func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
if gccMsg, ok := msg.(gccVersionCheckMsg); ok {
if gccMsg.err != nil || gccMsg.major < 15 {
m.state = StateGentooGCCCheck
return m, nil
}
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
}
return m, nil
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case " ":
m.skipGentooUseFlags = !m.skipGentooUseFlags
return m, nil
case "enter":
if m.selectedWM == 1 {
return m, m.checkGCCVersion()
}
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
}
return m, nil
case "esc":
m.state = StateDependencyReview
return m, nil
}
}
return m, m.listenForLogs()
}

View File

@@ -0,0 +1,333 @@
package tui
import (
"fmt"
"strings"
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 {
currentLine = word
} else if len(currentLine)+1+len(word) <= width {
currentLine += " " + word
} else {
result.WriteString(currentLine)
result.WriteString("\n")
currentLine = word
}
}
if len(currentLine) > 0 {
result.WriteString(currentLine)
}
return result.String()
}
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.WriteString("\n\n")
if !m.packageProgress.isComplete {
spinner := m.spinner.View()
status := m.styles.Normal.Render(m.packageProgress.step)
b.WriteString(fmt.Sprintf("%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.error != nil {
b.WriteString("\n")
wrappedErrorMsg := wrapText("Error: "+m.packageProgress.error.Error(), 80)
errorMsg := m.styles.Error.Render(wrappedErrorMsg)
b.WriteString(errorMsg)
}
// 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)
}
}
return b.String()
}
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.WriteString("\n\n")
success := m.styles.Success.Render("✓ All packages installed and configurations deployed.")
b.WriteString(success)
b.WriteString("\n\n")
// Show what was accomplished
accomplishments := []string{
"• Window manager and dependencies installed",
"• Terminal and development tools configured",
"• Configuration files deployed with backups",
"• System optimized for DankMaterialShell",
}
for _, item := range accomplishments {
b.WriteString(m.styles.Subtle.Render(item))
b.WriteString("\n")
}
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.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\" \n\nPress Enter to exit.")
b.WriteString(info)
if m.logFilePath != "" {
b.WriteString("\n\n")
logInfo := m.styles.Subtle.Render(fmt.Sprintf("Full logs: %s", m.logFilePath))
b.WriteString(logInfo)
}
return b.String()
}
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.WriteString("\n\n")
if m.err != nil {
wrappedError := wrapText("✗ "+m.err.Error(), 80)
error := m.styles.Error.Render(wrappedError)
b.WriteString(error)
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("\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")
}
hint := m.styles.Subtle.Render("Press Ctrl+D for full debug logs")
b.WriteString(hint)
b.WriteString("\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")
}
help := m.styles.Subtle.Render("Press Enter to exit")
b.WriteString(help)
return b.String()
}
func (m Model) updateInstallingPackagesState(msg tea.Msg) (tea.Model, tea.Cmd) {
if progressMsg, ok := msg.(packageInstallProgressMsg); ok {
m.packageProgress = progressMsg
// 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:]
}
}
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())
}
}
return m, m.listenForPackageProgress()
}
return m, m.listenForLogs()
}
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
}
}
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
}
}
return m, m.listenForLogs()
}
func (m Model) listenForPackageProgress() tea.Cmd {
return func() tea.Msg {
msg, ok := <-m.packageProgressChan
if !ok {
return packageProgressCompletedMsg{}
}
// Always return the message, completion will be handled in updateInstallingPackagesState
return msg
}
}
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("\n\n")
// Combine both logMessages and installationLogs
allLogs := append([]string{}, m.logMessages...)
allLogs = append(allLogs, m.installationLogs...)
if len(allLogs) == 0 {
b.WriteString("No logs available\n")
} else {
// Calculate available height (reserve space for header and footer)
maxHeight := m.height - 6
if maxHeight < 10 {
maxHeight = 10
}
// Show the most recent logs
startIdx := 0
if len(allLogs) > maxHeight {
startIdx = len(allLogs) - maxHeight
}
for i := startIdx; i < len(allLogs); i++ {
if allLogs[i] != "" {
b.WriteString(fmt.Sprintf("%d: %s\n", i, allLogs[i]))
}
}
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("\n")
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Accent))
b.WriteString(statusStyle.Render("Press Ctrl+D to return, Ctrl+C to quit"))
return b.String()
}

View File

@@ -0,0 +1,85 @@
package tui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) viewMissingWMInstructions() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n\n")
// Determine which WM is missing
wmName := "Niri"
installCmd := `environment.systemPackages = with pkgs; [
niri
];`
alternateCmd := `# Or enable the module if available:
# programs.niri.enable = true;`
if m.selectedWM == 1 {
wmName = "Hyprland"
installCmd = `programs.hyprland.enable = true;`
alternateCmd = `# Or add to systemPackages:
# environment.systemPackages = with pkgs; [
# hyprland
# ];`
}
// Title
title := m.styles.Title.Render("⚠️ " + wmName + " Not Installed")
b.WriteString(title)
b.WriteString("\n\n")
// Explanation
explanation := m.styles.Normal.Render(wmName + " needs to be installed system-wide on NixOS.")
b.WriteString(explanation)
b.WriteString("\n\n")
// Instructions
instructions := m.styles.Subtle.Render("To install " + wmName + ", add this to your /etc/nixos/configuration.nix:")
b.WriteString(instructions)
b.WriteString("\n\n")
// Command box
cmdBox := m.styles.CodeBlock.Render(installCmd)
b.WriteString(cmdBox)
b.WriteString("\n\n")
// Alternate command
altBox := m.styles.Subtle.Render(alternateCmd)
b.WriteString(altBox)
b.WriteString("\n\n")
// Rebuild instruction
rebuildInstruction := m.styles.Normal.Render("Then rebuild your system:")
b.WriteString(rebuildInstruction)
b.WriteString("\n")
rebuildCmd := m.styles.CodeBlock.Render("sudo nixos-rebuild switch")
b.WriteString(rebuildCmd)
b.WriteString("\n\n")
// Navigation help
help := m.styles.Subtle.Render("Press Esc to go back and select a different window manager, or Ctrl+C to exit")
b.WriteString(help)
return b.String()
}
func (m Model) updateMissingWMInstructionsState(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "esc":
// Go back to window manager selection
m.state = StateSelectWindowManager
return m, m.listenForLogs()
case "ctrl+c":
return m, tea.Quit
}
}
return m, m.listenForLogs()
}

View File

@@ -0,0 +1,338 @@
package tui
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
)
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.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)
return b.String()
}
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.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...")
b.WriteString(fmt.Sprintf("%s %s", spinner, status))
}
return b.String()
}
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.WriteString("\n\n")
message := "Installation requires sudo privileges.\nPlease enter your password to continue:"
b.WriteString(m.styles.Normal.Render(message))
b.WriteString("\n\n")
// Password input
b.WriteString(m.passwordInput.View())
b.WriteString("\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")
}
b.WriteString("\n")
help := m.styles.Subtle.Render("Enter: Continue, Esc: Back, Ctrl+C: Cancel")
b.WriteString(help)
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
}
}
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()
}
}
if _, ok := msg.(delayCompleteMsg); ok {
m.fingerprintFailed = false
m.selectedConfig = 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 {
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
}
}
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..." {
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)
case "esc":
// Go back to dependency review
m.passwordInput.SetValue("")
m.packageProgress = packageInstallProgressMsg{} // Clear any validation state
m.state = StateDependencyReview
return m, nil
}
}
m.passwordInput, cmd = m.passwordInput.Update(msg)
return m, cmd
}
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 {
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()
if err != nil {
return false
}
// If output contains "finger:" or similar, fingerprints are enrolled
return strings.Contains(string(output), "finger")
}
func (m Model) delayThenReturn() tea.Cmd {
return func() tea.Msg {
time.Sleep(2 * time.Second)
return delayCompleteMsg{}
}
}
func (m Model) tryFingerprint() tea.Cmd {
return func() tea.Msg {
clearCmd := exec.Command("sudo", "-k")
clearCmd.Run()
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), 0700); err != nil {
return passwordValidMsg{password: "", valid: false}
}
defer os.Remove(askpassScript)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-A", "-v")
cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript))
err := cmd.Run()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: true}
}
}
func (m Model) validatePassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
if err := cmd.Start(); err != nil {
return passwordValidMsg{password: "", valid: false}
}
_, err = fmt.Fprintf(stdin, "%s\n", password)
stdin.Close()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
err = cmd.Wait()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: password, valid: true}
}
}

View File

@@ -0,0 +1,244 @@
package tui
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea"
)
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."},
}
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
options = append(options, struct {
name string
description string
}{"Hyprland", "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()
}
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")
options := []struct {
name string
description string
}{}
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()
}
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
}
switch keyMsg.String() {
case "up":
if m.selectedTerminal > 0 {
m.selectedTerminal--
}
case "down":
if m.selectedTerminal < maxTerminalIndex {
m.selectedTerminal++
}
case "enter":
if m.osInfo != nil && m.osInfo.Distribution.ID == "nixos" {
var wmInstalled bool
if m.selectedWM == 0 {
wmInstalled = m.commandExists("niri")
} else {
wmInstalled = m.commandExists("hyprland") || m.commandExists("Hyprland")
}
if !wmInstalled {
m.state = StateMissingWMInstructions
return m, m.listenForLogs()
}
}
m.state = StateDetectingDeps
m.isLoading = true
return m, tea.Batch(m.spinner.Tick, m.detectDependencies())
case "esc":
m.state = StateSelectWindowManager
return m, m.listenForLogs()
}
}
return m, m.listenForLogs()
}
func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
maxWMIndex := 1
if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
maxWMIndex = 0
}
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()
}
}
return m, m.listenForLogs()
}
func (m Model) commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
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")}
}
detector, err := distros.NewDependencyDetector(m.osInfo.Distribution.ID, m.logChan)
if err != nil {
return depsDetectedMsg{deps: nil, err: err}
}
// Convert TUI selection to deps enum
var wm deps.WindowManager
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri // First option is Niri
} else {
wm = deps.WindowManagerHyprland // Second option is Hyprland
}
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)
return depsDetectedMsg{deps: dependencies, err: err}
}
}

View File

@@ -0,0 +1,216 @@
package tui
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
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.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 = "NixOS is currently not supported, but there is a DankMaterialShell flake available."
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)
}
b.WriteString(fmt.Sprintf(" %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...")
b.WriteString(fmt.Sprintf("%s %s\n\n", spinner, loading))
}
// 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)
}
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()
}
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()
}
}
}
return m, m.listenForLogs()
}