mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-20 02:04:01 -04:00
dankinstall: refactor tui package, single-source selection state
This commit is contained in:
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,5 @@ const (
|
||||
StateConfigConfirmation
|
||||
StateDeployingConfigs
|
||||
StateInstallComplete
|
||||
StateFinalComplete
|
||||
StateError
|
||||
)
|
||||
|
||||
+72
-43
@@ -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
@@ -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()}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user