diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index dae98dca..a3462ee1 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -513,5 +513,6 @@ func getCommonCommands() []*cobra.Command { notifyActionCmd, matugenCmd, clipboardCmd, + doctorCmd, } } diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go new file mode 100644 index 00000000..a96c3cd0 --- /dev/null +++ b/core/cmd/dms/commands_doctor.go @@ -0,0 +1,661 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/tui" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/version" + "github.com/spf13/cobra" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Diagnose DMS installation and dependencies", + Long: "Check system health, verify dependencies, and diagnose configuration issues for DMS", + Run: runDoctor, +} + +var doctorVerbose bool + +func init() { + doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions") +} + +type category int + +const ( + catSystem category = iota + catVersions + catInstallation + catCompositor + catQuickshellFeatures + catOptionalFeatures + catConfigFiles + catServices +) + +var categoryNames = []string{ + "System", "Versions", "Installation", "Compositor", + "Quickshell Features", "Optional Features", "Config Files", "Services", +} + +type checkResult struct { + category category + name string + status string + message string + details string +} + +func runDoctor(cmd *cobra.Command, args []string) { + printDoctorHeader() + + qsFeatures, qsMissingFeatures := checkQuickshellFeatures() + + results := slices.Concat( + checkSystemInfo(), + checkVersions(qsMissingFeatures), + checkDMSInstallation(), + checkWindowManagers(), + qsFeatures, + checkOptionalDependencies(), + checkConfigurationFiles(), + checkSystemdServices(), + ) + + printResults(results) + printSummary(results, qsMissingFeatures) +} + +func printDoctorHeader() { + theme := tui.TerminalTheme() + styles := tui.NewStyles(theme) + + fmt.Println(getThemedASCII()) + fmt.Println(styles.Title.Render("System Health Check")) + fmt.Println(styles.Subtle.Render("──────────────────────────────────────")) + fmt.Println() +} + +func checkSystemInfo() []checkResult { + results := []checkResult{} + + osInfo, err := distros.GetOSInfo() + if err != nil { + status, message, details := "warn", fmt.Sprintf("Unknown (%v)", err), "" + + if strings.Contains(err.Error(), "Unsupported distribution") { + osRelease := readOSRelease() + if osRelease["ID"] == "nixos" { + status = "ok" + message = osRelease["PRETTY_NAME"] + if message == "" { + message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"]) + } + details = "Supported for runtime (install via NixOS module or Flake)" + } else if osRelease["PRETTY_NAME"] != "" { + message = fmt.Sprintf("%s (not supported by dms setup)", osRelease["PRETTY_NAME"]) + details = "DMS may work but automatic installation is not available" + } + } + + results = append(results, checkResult{catSystem, "Operating System", status, message, details}) + } else { + status := "ok" + message := osInfo.PrettyName + if message == "" { + message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID) + } + if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { + status = "warn" + message += " (version may not be fully supported)" + } + results = append(results, checkResult{ + catSystem, "Operating System", status, message, + fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture), + }) + } + + arch := runtime.GOARCH + archStatus := "ok" + if arch != "amd64" && arch != "arm64" { + archStatus = "error" + } + results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, ""}) + + waylandDisplay := os.Getenv("WAYLAND_DISPLAY") + xdgSessionType := os.Getenv("XDG_SESSION_TYPE") + + switch { + case waylandDisplay != "" || xdgSessionType == "wayland": + results = append(results, checkResult{ + catSystem, "Display Server", "ok", "Wayland", + fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay), + }) + case xdgSessionType == "x11": + results = append(results, checkResult{catSystem, "Display Server", "error", "X11 (DMS requires Wayland)", ""}) + default: + results = append(results, checkResult{ + catSystem, "Display Server", "warn", "Unknown (ensure you're running Wayland)", + fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType), + }) + } + + return results +} + +func readOSRelease() map[string]string { + result := make(map[string]string) + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return result + } + for line := range strings.SplitSeq(string(data), "\n") { + if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { + result[parts[0]] = strings.Trim(parts[1], "\"") + } + } + return result +} + +func checkVersions(qsMissingFeatures bool) []checkResult { + results := []checkResult{ + {catVersions, "DMS CLI", "info", formatVersion(Version), ""}, + } + + qsVersion, qsStatus := getQuickshellVersionInfo(qsMissingFeatures) + results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, ""}) + + dmsVersion, dmsPath := getDMSShellVersion() + if dmsVersion != "" { + results = append(results, checkResult{catVersions, "DMS Shell", "ok", dmsVersion, dmsPath}) + } else { + results = append(results, checkResult{catVersions, "DMS Shell", "error", "Not installed or not detected", "Run 'dms setup' to install"}) + } + + return results +} + +func getDMSShellVersion() (version, path string) { + if err := findConfig(nil, nil); err == nil && configPath != "" { + versionFile := filepath.Join(configPath, "VERSION") + if data, err := os.ReadFile(versionFile); err == nil { + return strings.TrimSpace(string(data)), configPath + } + return "installed", configPath + } + + if dmsPath, err := config.LocateDMSConfig(); err == nil { + versionFile := filepath.Join(dmsPath, "VERSION") + if data, err := os.ReadFile(versionFile); err == nil { + return strings.TrimSpace(string(data)), dmsPath + } + return "installed", dmsPath + } + + return "", "" +} + +func getQuickshellVersionInfo(missingFeatures bool) (string, string) { + if !utils.CommandExists("qs") { + return "Not installed", "error" + } + + output, err := exec.Command("qs", "--version").Output() + if err != nil { + return "Installed (version check failed)", "warn" + } + + fullVersion := strings.TrimSpace(string(output)) + if matches := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`).FindStringSubmatch(fullVersion); len(matches) >= 2 { + if version.CompareVersions(matches[1], "0.2.0") < 0 { + return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), "error" + } + if missingFeatures { + return fullVersion, "warn" + } + return fullVersion, "ok" + } + + return fullVersion, "warn" +} + +func checkDMSInstallation() []checkResult { + results := []checkResult{} + + dmsPath := "" + if err := findConfig(nil, nil); err == nil && configPath != "" { + dmsPath = configPath + } else if path, err := config.LocateDMSConfig(); err == nil { + dmsPath = path + } + + if dmsPath == "" { + return []checkResult{{catInstallation, "DMS Configuration", "error", "Not found", "shell.qml not found in any config path"}} + } + + results = append(results, checkResult{catInstallation, "DMS Configuration", "ok", "Found", dmsPath}) + + shellQml := filepath.Join(dmsPath, "shell.qml") + if _, err := os.Stat(shellQml); err != nil { + results = append(results, checkResult{catInstallation, "shell.qml", "error", "Missing", shellQml}) + } else { + results = append(results, checkResult{catInstallation, "shell.qml", "ok", "Present", shellQml}) + } + + if doctorVerbose { + installType := "Unknown" + switch { + case strings.Contains(dmsPath, "/nix/store"): + installType = "Nix store" + case strings.Contains(dmsPath, ".local/share") || strings.Contains(dmsPath, "/usr/share"): + installType = "System package" + case strings.Contains(dmsPath, ".config"): + installType = "User config" + } + results = append(results, checkResult{catInstallation, "Install Type", "info", installType, dmsPath}) + } + + return results +} + +func checkWindowManagers() []checkResult { + compositors := []struct { + name, versionCmd, versionArg, versionRe string + commands []string + }{ + {"Hyprland", "hyprctl", "version", `v?(\d+\.\d+\.\d+)`, []string{"hyprland", "Hyprland"}}, + {"niri", "niri", "--version", `niri (\d+\.\d+)`, []string{"niri"}}, + {"Sway", "sway", "--version", `sway version (\d+\.\d+)`, []string{"sway"}}, + {"River", "river", "-version", `river (\d+\.\d+)`, []string{"river"}}, + {"Wayfire", "wayfire", "--version", `wayfire (\d+\.\d+)`, []string{"wayfire"}}, + } + + results := []checkResult{} + foundAny := false + + for _, c := range compositors { + if slices.ContainsFunc(c.commands, utils.CommandExists) { + foundAny = true + results = append(results, checkResult{ + catCompositor, c.name, "ok", + getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRe), "", + }) + } + } + + if !foundAny { + results = append(results, checkResult{ + catCompositor, "Compositor", "error", + "No supported Wayland compositor found", + "Install Hyprland, niri, Sway, River, or Wayfire", + }) + } + + if wm := detectRunningWM(); wm != "" { + results = append(results, checkResult{catCompositor, "Active", "info", wm, ""}) + } + + return results +} + +func getVersionFromCommand(cmd, arg, regex string) string { + output, err := exec.Command(cmd, arg).Output() + if err != nil { + return "installed" + } + + outStr := string(output) + if matches := regexp.MustCompile(regex).FindStringSubmatch(outStr); len(matches) > 1 { + ver := matches[1] + if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { + return ver + " (git)" + } + return ver + } + return strings.TrimSpace(outStr) +} + +func detectRunningWM() string { + switch { + case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "": + return "Hyprland" + case os.Getenv("NIRI_SOCKET") != "": + return "niri" + case os.Getenv("XDG_CURRENT_DESKTOP") != "": + return os.Getenv("XDG_CURRENT_DESKTOP") + } + return "" +} + +func checkQuickshellFeatures() ([]checkResult, bool) { + if !utils.CommandExists("qs") { + return nil, false + } + + tmpDir := os.TempDir() + testScript := filepath.Join(tmpDir, "qs-feature-test.qml") + defer os.Remove(testScript) + + qmlContent := ` +import QtQuick +import Quickshell + +ShellRoot { + id: root + + property bool polkitAvailable: false + property bool idleMonitorAvailable: false + property bool idleInhibitorAvailable: false + property bool shortcutInhibitorAvailable: false + + Timer { + interval: 50 + running: true + repeat: false + onTriggered: { + try { + var polkitTest = Qt.createQmlObject( + 'import Quickshell.Services.Polkit; import QtQuick; Item {}', + root + ) + root.polkitAvailable = true + polkitTest.destroy() + } catch (e) {} + + try { + var testItem = Qt.createQmlObject( + 'import Quickshell.Wayland; import QtQuick; QtObject { ' + + 'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' + + 'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' + + 'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' + + '}', + root + ) + root.idleMonitorAvailable = testItem.hasIdleMonitor + root.idleInhibitorAvailable = testItem.hasIdleInhibitor + root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor + testItem.destroy() + } catch (e) {} + + console.log(root.polkitAvailable ? "FEATURE:Polkit:OK" : "FEATURE:Polkit:UNAVAILABLE") + console.log(root.idleMonitorAvailable ? "FEATURE:IdleMonitor:OK" : "FEATURE:IdleMonitor:UNAVAILABLE") + console.log(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE") + console.log(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE") + + Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)]) + } + } +} +` + + if err := os.WriteFile(testScript, []byte(qmlContent), 0644); err != nil { + return nil, false + } + + cmd := exec.Command("qs", "-p", testScript) + cmd.Env = append(os.Environ(), "NO_COLOR=1") + output, _ := cmd.CombinedOutput() + outputStr := string(output) + + features := []struct{ name, desc string }{ + {"Polkit", "Authentication prompts"}, + {"IdleMonitor", "Idle detection"}, + {"IdleInhibitor", "Prevent idle/sleep"}, + {"ShortcutInhibitor", "Allow shortcut management (niri)"}, + } + + results := []checkResult{} + missingFeatures := false + + for _, f := range features { + available := strings.Contains(outputStr, fmt.Sprintf("FEATURE:%s:OK", f.name)) + status, message := "ok", "Available" + if !available { + status, message = "info", "Not available" + missingFeatures = true + } + results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc}) + } + + return results, missingFeatures +} + +func checkOptionalDependencies() []checkResult { + results := []checkResult{} + + if utils.IsServiceActive("accounts-daemon", false) { + results = append(results, checkResult{catOptionalFeatures, "accountsservice", "ok", "Running", "User accounts"}) + } else { + results = append(results, checkResult{catOptionalFeatures, "accountsservice", "warn", "Not running", "User accounts"}) + } + + terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"} + terminalFound := "" + for _, term := range terminals { + if utils.CommandExists(term) { + terminalFound = term + break + } + } + if terminalFound != "" { + results = append(results, checkResult{catOptionalFeatures, "Terminal", "ok", terminalFound, ""}) + } else { + results = append(results, checkResult{catOptionalFeatures, "Terminal", "warn", "None found", "Install ghostty, kitty, or alacritty"}) + } + + deps := []struct { + name, cmd, altCmd, desc string + important bool + }{ + {"matugen", "matugen", "", "Dynamic theming", true}, + {"dgop", "dgop", "", "System monitoring", true}, + {"cava", "cava", "", "Audio waveform", false}, + {"khal", "khal", "", "Calendar events", false}, + {"Network", "nmcli", "iwctl", "Network management", false}, + {"danksearch", "dsearch", "", "File search", false}, + {"loginctl", "loginctl", "", "Session management", false}, + {"fprintd", "fprintd-list", "", "Fingerprint auth", false}, + } + + for _, d := range deps { + found, foundCmd := utils.CommandExists(d.cmd), d.cmd + if !found && d.altCmd != "" { + if utils.CommandExists(d.altCmd) { + found, foundCmd = true, d.altCmd + } + } + + if found { + message := "Installed" + switch foundCmd { + case "nmcli": + message = "NetworkManager" + case "iwctl": + message = "iwd" + } + results = append(results, checkResult{catOptionalFeatures, d.name, "ok", message, d.desc}) + } else if d.important { + results = append(results, checkResult{catOptionalFeatures, d.name, "warn", "Missing", d.desc}) + } else { + results = append(results, checkResult{catOptionalFeatures, d.name, "info", "Not installed", d.desc}) + } + } + + return results +} + +func checkConfigurationFiles() []checkResult { + configFiles := []struct{ name, path string }{ + {"Settings", filepath.Join(utils.XDGConfigHome(), "DankMaterialShell", "settings.json")}, + {"Session", filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "session.json")}, + {"Colors", filepath.Join(utils.XDGCacheHome(), "DankMaterialShell", "dms-colors.json")}, + } + + results := []checkResult{} + for _, cf := range configFiles { + if _, err := os.Stat(cf.path); err == nil { + results = append(results, checkResult{catConfigFiles, cf.name, "ok", "Present", cf.path}) + } else { + results = append(results, checkResult{catConfigFiles, cf.name, "info", "Not yet created", cf.path}) + } + } + return results +} + +func checkSystemdServices() []checkResult { + if !utils.CommandExists("systemctl") { + return nil + } + + results := []checkResult{} + + dmsState := getServiceState("dms", true) + if !dmsState.exists { + results = append(results, checkResult{catServices, "dms.service", "info", "Not installed", "Optional user service"}) + } else { + status, message := "ok", dmsState.enabled + if dmsState.active != "" { + message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active) + } + if dmsState.enabled == "disabled" { + status, message = "warn", "Disabled" + } + results = append(results, checkResult{catServices, "dms.service", status, message, ""}) + } + + greetdState := getServiceState("greetd", false) + if greetdState.exists { + status := "ok" + if greetdState.enabled == "disabled" { + status = "info" + } + results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, ""}) + } else if doctorVerbose { + results = append(results, checkResult{catServices, "greetd", "info", "Not installed", "Optional greeter service"}) + } + + return results +} + +type serviceState struct { + exists bool + enabled string + active string +} + +func getServiceState(name string, userService bool) serviceState { + args := []string{"is-enabled", name} + if userService { + args = []string{"--user", "is-enabled", name} + } + + output, _ := exec.Command("systemctl", args...).Output() + enabled := strings.TrimSpace(string(output)) + + if enabled == "" || enabled == "not-found" { + return serviceState{} + } + + state := serviceState{exists: true, enabled: enabled} + + if userService { + output, _ = exec.Command("systemctl", "--user", "is-active", name).Output() + if active := strings.TrimSpace(string(output)); active != "" && active != "unknown" { + state.active = active + } + } + + return state +} + +func printResults(results []checkResult) { + theme := tui.TerminalTheme() + styles := tui.NewStyles(theme) + + currentCategory := category(-1) + for _, r := range results { + if r.category != currentCategory { + if currentCategory != -1 { + fmt.Println() + } + fmt.Printf(" %s\n", styles.Bold.Render(categoryNames[r.category])) + currentCategory = r.category + } + printResultLine(r, styles) + } +} + +func printResultLine(r checkResult, styles tui.Styles) { + icon, style := "○", styles.Subtle + switch r.status { + case "ok": + icon, style = "●", styles.Success + case "warn": + icon, style = "●", styles.Warning + case "error": + icon, style = "●", styles.Error + } + + name := r.name + if len(name) > 18 { + name = name[:17] + "…" + } + dots := strings.Repeat("·", 19-len(name)) + + fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message) + + if doctorVerbose && r.details != "" { + fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details)) + } +} + +func printSummary(results []checkResult, qsMissingFeatures bool) { + theme := tui.TerminalTheme() + styles := tui.NewStyles(theme) + + errors, warnings, ok := 0, 0, 0 + for _, r := range results { + switch r.status { + case "error": + errors++ + case "warn": + warnings++ + case "ok": + ok++ + } + } + + fmt.Println() + fmt.Printf(" %s\n", styles.Subtle.Render("──────────────────────────────────────")) + + if errors == 0 && warnings == 0 { + fmt.Printf(" %s\n", styles.Success.Render("✓ All checks passed!")) + } else { + parts := []string{} + if errors > 0 { + parts = append(parts, styles.Error.Render(fmt.Sprintf("%d error(s)", errors))) + } + if warnings > 0 { + parts = append(parts, styles.Warning.Render(fmt.Sprintf("%d warning(s)", warnings))) + } + parts = append(parts, styles.Success.Render(fmt.Sprintf("%d ok", ok))) + fmt.Printf(" %s\n", strings.Join(parts, ", ")) + + if qsMissingFeatures { + fmt.Println() + fmt.Printf(" %s\n", styles.Subtle.Render("→ Consider using quickshell-git for full feature support")) + } + } + fmt.Println() +} diff --git a/core/internal/utils/exec.go b/core/internal/utils/exec.go index 085bde9f..019477b5 100644 --- a/core/internal/utils/exec.go +++ b/core/internal/utils/exec.go @@ -1,6 +1,9 @@ package utils -import "os/exec" +import ( + "os/exec" + "strings" +) func CommandExists(cmd string) bool { _, err := exec.LookPath(cmd) @@ -15,3 +18,16 @@ func AnyCommandExists(cmds ...string) bool { } return false } + +func IsServiceActive(name string, userService bool) bool { + if !CommandExists("systemctl") { + return false + } + + args := []string{"is-active", name} + if userService { + args = []string{"--user", "is-active", name} + } + output, _ := exec.Command("systemctl", args...).Output() + return strings.TrimSpace(string(output)) == "active" +} diff --git a/core/internal/utils/paths.go b/core/internal/utils/paths.go index 82330b66..d7c46396 100644 --- a/core/internal/utils/paths.go +++ b/core/internal/utils/paths.go @@ -6,6 +6,30 @@ import ( "strings" ) +func xdgDir(envVar string, defaultPath ...string) string { + if dir := os.Getenv(envVar); dir != "" { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(append([]string{home}, defaultPath...)...) +} + +func XDGConfigHome() string { + return xdgDir("XDG_CONFIG_HOME", ".config") +} + +func XDGStateHome() string { + return xdgDir("XDG_STATE_HOME", ".local", "state") +} + +func XDGCacheHome() string { + return xdgDir("XDG_CACHE_HOME", ".cache") +} + +func XDGDataHome() string { + return xdgDir("XDG_DATA_HOME", ".local", "share") +} + func ExpandPath(path string) (string, error) { expanded := os.ExpandEnv(path) expanded = filepath.Clean(expanded)