mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 13:32:50 -05:00
932 lines
28 KiB
Go
932 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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/server/brightness"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type status string
|
|
|
|
const (
|
|
statusOK status = "ok"
|
|
statusWarn status = "warn"
|
|
statusError status = "error"
|
|
statusInfo status = "info"
|
|
)
|
|
|
|
func (s status) IconStyle(styles tui.Styles) (string, lipgloss.Style) {
|
|
switch s {
|
|
case statusOK:
|
|
return "●", styles.Success
|
|
case statusWarn:
|
|
return "●", styles.Warning
|
|
case statusError:
|
|
return "●", styles.Error
|
|
default:
|
|
return "○", styles.Subtle
|
|
}
|
|
}
|
|
|
|
type DoctorStatus struct {
|
|
Errors []checkResult
|
|
Warnings []checkResult
|
|
OK []checkResult
|
|
Info []checkResult
|
|
}
|
|
|
|
func (ds *DoctorStatus) Add(r checkResult) {
|
|
switch r.status {
|
|
case statusError:
|
|
ds.Errors = append(ds.Errors, r)
|
|
case statusWarn:
|
|
ds.Warnings = append(ds.Warnings, r)
|
|
case statusOK:
|
|
ds.OK = append(ds.OK, r)
|
|
case statusInfo:
|
|
ds.Info = append(ds.Info, r)
|
|
}
|
|
}
|
|
|
|
func (ds *DoctorStatus) HasIssues() bool {
|
|
return len(ds.Errors) > 0 || len(ds.Warnings) > 0
|
|
}
|
|
|
|
func (ds *DoctorStatus) ErrorCount() int {
|
|
return len(ds.Errors)
|
|
}
|
|
|
|
func (ds *DoctorStatus) WarningCount() int {
|
|
return len(ds.Warnings)
|
|
}
|
|
|
|
func (ds *DoctorStatus) OKCount() int {
|
|
return len(ds.OK)
|
|
}
|
|
|
|
var (
|
|
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
|
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
|
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
|
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
|
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
|
|
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
|
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
|
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
|
)
|
|
|
|
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
|
|
doctorJSON bool
|
|
)
|
|
|
|
func init() {
|
|
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
|
|
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
|
|
}
|
|
|
|
type category int
|
|
|
|
const (
|
|
catSystem category = iota
|
|
catVersions
|
|
catInstallation
|
|
catCompositor
|
|
catQuickshellFeatures
|
|
catOptionalFeatures
|
|
catConfigFiles
|
|
catServices
|
|
catEnvironment
|
|
)
|
|
|
|
func (c category) String() string {
|
|
switch c {
|
|
case catSystem:
|
|
return "System"
|
|
case catVersions:
|
|
return "Versions"
|
|
case catInstallation:
|
|
return "Installation"
|
|
case catCompositor:
|
|
return "Compositor"
|
|
case catQuickshellFeatures:
|
|
return "Quickshell Features"
|
|
case catOptionalFeatures:
|
|
return "Optional Features"
|
|
case catConfigFiles:
|
|
return "Config Files"
|
|
case catServices:
|
|
return "Services"
|
|
case catEnvironment:
|
|
return "Environment"
|
|
default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
const (
|
|
checkNameMaxLength = 21
|
|
doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
|
|
)
|
|
|
|
type checkResult struct {
|
|
category category
|
|
name string
|
|
status status
|
|
message string
|
|
details string
|
|
url string
|
|
}
|
|
|
|
type checkResultJSON struct {
|
|
Category string `json:"category"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
Details string `json:"details,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
type doctorOutputJSON struct {
|
|
Summary struct {
|
|
Errors int `json:"errors"`
|
|
Warnings int `json:"warnings"`
|
|
OK int `json:"ok"`
|
|
Info int `json:"info"`
|
|
} `json:"summary"`
|
|
Results []checkResultJSON `json:"results"`
|
|
}
|
|
|
|
func (r checkResult) toJSON() checkResultJSON {
|
|
return checkResultJSON{
|
|
Category: r.category.String(),
|
|
Name: r.name,
|
|
Status: string(r.status),
|
|
Message: r.message,
|
|
Details: r.details,
|
|
URL: r.url,
|
|
}
|
|
}
|
|
|
|
func runDoctor(cmd *cobra.Command, args []string) {
|
|
if !doctorJSON {
|
|
printDoctorHeader()
|
|
}
|
|
|
|
qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
|
|
|
|
results := slices.Concat(
|
|
checkSystemInfo(),
|
|
checkVersions(qsMissingFeatures),
|
|
checkDMSInstallation(),
|
|
checkWindowManagers(),
|
|
qsFeatures,
|
|
checkOptionalDependencies(),
|
|
checkConfigurationFiles(),
|
|
checkSystemdServices(),
|
|
checkEnvironmentVars(),
|
|
)
|
|
|
|
if doctorJSON {
|
|
printResultsJSON(results)
|
|
} else {
|
|
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 {
|
|
var results []checkResult
|
|
|
|
osInfo, err := distros.GetOSInfo()
|
|
if err != nil {
|
|
status, message, details := statusWarn, fmt.Sprintf("Unknown (%v)", err), ""
|
|
|
|
if strings.Contains(err.Error(), "Unsupported distribution") {
|
|
osRelease := readOSRelease()
|
|
switch {
|
|
case osRelease["ID"] == "nixos":
|
|
status = statusOK
|
|
message = osRelease["PRETTY_NAME"]
|
|
if message == "" {
|
|
message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"])
|
|
}
|
|
details = "Supported for runtime (install via NixOS module or Flake)"
|
|
case 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, doctorDocsURL + "#operating-system"})
|
|
} else {
|
|
status := statusOK
|
|
message := osInfo.PrettyName
|
|
if message == "" {
|
|
message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID)
|
|
}
|
|
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
|
status = statusWarn
|
|
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),
|
|
doctorDocsURL + "#operating-system",
|
|
})
|
|
}
|
|
|
|
arch := runtime.GOARCH
|
|
archStatus := statusOK
|
|
if arch != "amd64" && arch != "arm64" {
|
|
archStatus = statusError
|
|
}
|
|
results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, "", doctorDocsURL + "#architecture"})
|
|
|
|
waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
|
|
xdgSessionType := os.Getenv("XDG_SESSION_TYPE")
|
|
|
|
switch {
|
|
case waylandDisplay != "" || xdgSessionType == "wayland":
|
|
results = append(results, checkResult{
|
|
catSystem, "Display Server", statusOK, "Wayland",
|
|
fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay),
|
|
doctorDocsURL + "#display-server",
|
|
})
|
|
case xdgSessionType == "x11":
|
|
results = append(results, checkResult{catSystem, "Display Server", statusError, "X11 (DMS requires Wayland)", "", doctorDocsURL + "#display-server"})
|
|
default:
|
|
results = append(results, checkResult{
|
|
catSystem, "Display Server", statusWarn, "Unknown (ensure you're running Wayland)",
|
|
fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType),
|
|
doctorDocsURL + "#display-server",
|
|
})
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func checkEnvironmentVars() []checkResult {
|
|
var results []checkResult
|
|
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
|
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
|
return results
|
|
}
|
|
|
|
func checkEnvVar(name string) []checkResult {
|
|
value := os.Getenv(name)
|
|
if value != "" {
|
|
return []checkResult{{catEnvironment, name, statusInfo, value, "", doctorDocsURL + "#environment-variables"}}
|
|
}
|
|
if doctorVerbose {
|
|
return []checkResult{{catEnvironment, name, statusInfo, "Not set", "", doctorDocsURL + "#environment-variables"}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
dmsCliPath, _ := os.Executable()
|
|
dmsCliDetails := ""
|
|
if doctorVerbose {
|
|
dmsCliDetails = dmsCliPath
|
|
}
|
|
|
|
results := []checkResult{
|
|
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"},
|
|
}
|
|
|
|
qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures)
|
|
qsDetails := ""
|
|
if doctorVerbose && qsPath != "" {
|
|
qsDetails = qsPath
|
|
}
|
|
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails, doctorDocsURL + "#quickshell"})
|
|
|
|
dmsVersion, dmsPath := getDMSShellVersion()
|
|
if dmsVersion != "" {
|
|
results = append(results, checkResult{catVersions, "DMS Shell", statusOK, dmsVersion, dmsPath, doctorDocsURL + "#dms-shell"})
|
|
} else {
|
|
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"})
|
|
}
|
|
|
|
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, status, string) {
|
|
if !utils.CommandExists("qs") {
|
|
return "Not installed", statusError, ""
|
|
}
|
|
|
|
qsPath, _ := exec.LookPath("qs")
|
|
|
|
output, err := exec.Command("qs", "--version").Output()
|
|
if err != nil {
|
|
return "Installed (version check failed)", statusWarn, qsPath
|
|
}
|
|
|
|
fullVersion := strings.TrimSpace(string(output))
|
|
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 {
|
|
if version.CompareVersions(matches[1], "0.2.0") < 0 {
|
|
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath
|
|
}
|
|
if missingFeatures {
|
|
return fullVersion, statusWarn, qsPath
|
|
}
|
|
return fullVersion, statusOK, qsPath
|
|
}
|
|
|
|
return fullVersion, statusWarn, qsPath
|
|
}
|
|
|
|
func checkDMSInstallation() []checkResult {
|
|
var 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", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
|
|
}
|
|
|
|
results = append(results, checkResult{catInstallation, "DMS Configuration", statusOK, "Found", dmsPath, doctorDocsURL + "#dms-configuration"})
|
|
|
|
shellQml := filepath.Join(dmsPath, "shell.qml")
|
|
if _, err := os.Stat(shellQml); err != nil {
|
|
results = append(results, checkResult{catInstallation, "shell.qml", statusError, "Missing", shellQml, doctorDocsURL + "#dms-configuration"})
|
|
} else {
|
|
results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml, doctorDocsURL + "#dms-configuration"})
|
|
}
|
|
|
|
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", statusInfo, installType, dmsPath, doctorDocsURL + "#dms-configuration"})
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func checkWindowManagers() []checkResult {
|
|
compositors := []struct {
|
|
name, versionCmd, versionArg string
|
|
versionRegex *regexp.Regexp
|
|
commands []string
|
|
}{
|
|
{"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
|
|
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
|
|
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
|
|
{"River", "river", "-version", riverVersionRegex, []string{"river"}},
|
|
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
|
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
|
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
|
}
|
|
|
|
var results []checkResult
|
|
foundAny := false
|
|
|
|
for _, c := range compositors {
|
|
if !slices.ContainsFunc(c.commands, utils.CommandExists) {
|
|
continue
|
|
}
|
|
foundAny = true
|
|
var compositorPath string
|
|
for _, cmd := range c.commands {
|
|
if path, err := exec.LookPath(cmd); err == nil {
|
|
compositorPath = path
|
|
break
|
|
}
|
|
}
|
|
details := ""
|
|
if doctorVerbose && compositorPath != "" {
|
|
details = compositorPath
|
|
}
|
|
results = append(results, checkResult{
|
|
catCompositor, c.name, statusOK,
|
|
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
|
|
doctorDocsURL + "#compositor-checks",
|
|
})
|
|
}
|
|
|
|
if !foundAny {
|
|
results = append(results, checkResult{
|
|
catCompositor, "Compositor", statusError,
|
|
"No supported Wayland compositor found",
|
|
"Install Hyprland, niri, Sway, River, or Wayfire",
|
|
doctorDocsURL + "#compositor-checks",
|
|
})
|
|
}
|
|
|
|
if wm := detectRunningWM(); wm != "" {
|
|
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
|
output, err := exec.Command(cmd, arg).CombinedOutput()
|
|
if err != nil && len(output) == 0 {
|
|
return "installed"
|
|
}
|
|
|
|
outStr := string(output)
|
|
if matches := 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.warn(root.polkitAvailable ? "FEATURE:Polkit:OK" : "FEATURE:Polkit:UNAVAILABLE")
|
|
console.warn(root.idleMonitorAvailable ? "FEATURE:IdleMonitor:OK" : "FEATURE:IdleMonitor:UNAVAILABLE")
|
|
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
|
|
console.warn(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)"},
|
|
}
|
|
|
|
var results []checkResult
|
|
missingFeatures := false
|
|
|
|
for _, f := range features {
|
|
available := strings.Contains(outputStr, fmt.Sprintf("FEATURE:%s:OK", f.name))
|
|
status, message := statusOK, "Available"
|
|
if !available {
|
|
status, message = statusInfo, "Not available"
|
|
missingFeatures = true
|
|
}
|
|
results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc, doctorDocsURL + "#quickshell-features"})
|
|
}
|
|
|
|
return results, missingFeatures
|
|
}
|
|
|
|
func checkI2CAvailability() checkResult {
|
|
ddc, err := brightness.NewDDCBackend()
|
|
if err != nil {
|
|
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
|
}
|
|
defer ddc.Close()
|
|
|
|
devices, err := ddc.GetDevices()
|
|
if err != nil || len(devices) == 0 {
|
|
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "No monitors detected", "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
|
}
|
|
|
|
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
|
}
|
|
|
|
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
|
switch stackResult.Backend {
|
|
case network.BackendNetworkManager:
|
|
return "NetworkManager"
|
|
case network.BackendIwd:
|
|
return "iwd"
|
|
case network.BackendNetworkd:
|
|
if stackResult.HasIwd {
|
|
return "iwd + systemd-networkd"
|
|
}
|
|
return "systemd-networkd"
|
|
case network.BackendConnMan:
|
|
return "ConnMan"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func getOptionalDBusStatus(busName string) (status, string) {
|
|
if utils.IsDBusServiceAvailable(busName) {
|
|
return statusOK, "Available"
|
|
} else {
|
|
return statusWarn, "Not available"
|
|
}
|
|
}
|
|
|
|
func checkOptionalDependencies() []checkResult {
|
|
var results []checkResult
|
|
|
|
optionalFeaturesURL := doctorDocsURL + "#optional-features"
|
|
|
|
accountsStatus, accountsMsg := getOptionalDBusStatus("org.freedesktop.Accounts")
|
|
results = append(results, checkResult{catOptionalFeatures, "accountsservice", accountsStatus, accountsMsg, "User accounts", optionalFeaturesURL})
|
|
|
|
ppdStatus, ppdMsg := getOptionalDBusStatus("org.freedesktop.UPower.PowerProfiles")
|
|
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", ppdStatus, ppdMsg, "Power profile management", optionalFeaturesURL})
|
|
|
|
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
|
|
results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL})
|
|
|
|
results = append(results, checkI2CAvailability())
|
|
|
|
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
|
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
|
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
|
|
} else {
|
|
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
|
|
}
|
|
|
|
networkResult, err := network.DetectNetworkStack()
|
|
networkStatus, networkMessage, networkDetails := statusOK, "Not available", "Network management"
|
|
|
|
if err == nil && networkResult.Backend != network.BackendNone {
|
|
networkMessage = detectNetworkBackend(networkResult)
|
|
if doctorVerbose {
|
|
networkDetails = networkResult.ChosenReason
|
|
}
|
|
} else {
|
|
networkStatus = statusInfo
|
|
}
|
|
|
|
results = append(results, checkResult{catOptionalFeatures, "Network", networkStatus, networkMessage, networkDetails, optionalFeaturesURL})
|
|
|
|
deps := []struct {
|
|
name, cmd, desc string
|
|
important bool
|
|
}{
|
|
{"matugen", "matugen", "Dynamic theming", true},
|
|
{"dgop", "dgop", "System monitoring", true},
|
|
{"cava", "cava", "Audio visualizer", true},
|
|
{"khal", "khal", "Calendar events", false},
|
|
{"danksearch", "dsearch", "File search", false},
|
|
{"fprintd", "fprintd-list", "Fingerprint auth", false},
|
|
}
|
|
|
|
for _, d := range deps {
|
|
found := utils.CommandExists(d.cmd)
|
|
|
|
switch {
|
|
case found:
|
|
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, "Installed", d.desc, optionalFeaturesURL})
|
|
case d.important:
|
|
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, optionalFeaturesURL})
|
|
default:
|
|
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, optionalFeaturesURL})
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func checkConfigurationFiles() []checkResult {
|
|
configDir, _ := os.UserConfigDir()
|
|
cacheDir, _ := os.UserCacheDir()
|
|
dmsDir := "DankMaterialShell"
|
|
|
|
configFiles := []struct{ name, path string }{
|
|
{"settings.json", filepath.Join(configDir, dmsDir, "settings.json")},
|
|
{"clsettings.json", filepath.Join(configDir, dmsDir, "clsettings.json")},
|
|
{"plugin_settings.json", filepath.Join(configDir, dmsDir, "plugin_settings.json")},
|
|
{"session.json", filepath.Join(utils.XDGStateHome(), dmsDir, "session.json")},
|
|
{"dms-colors.json", filepath.Join(cacheDir, dmsDir, "dms-colors.json")},
|
|
}
|
|
|
|
var results []checkResult
|
|
for _, cf := range configFiles {
|
|
info, err := os.Stat(cf.path)
|
|
if err != nil {
|
|
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path, doctorDocsURL + "#config-files"})
|
|
continue
|
|
}
|
|
|
|
status := statusOK
|
|
message := "Present"
|
|
if info.Mode().Perm()&0200 == 0 {
|
|
status = statusWarn
|
|
message += " (read-only)"
|
|
}
|
|
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path, doctorDocsURL + "#config-files"})
|
|
}
|
|
return results
|
|
}
|
|
|
|
func checkSystemdServices() []checkResult {
|
|
if !utils.CommandExists("systemctl") {
|
|
return nil
|
|
}
|
|
|
|
var results []checkResult
|
|
|
|
dmsState := getServiceState("dms", true)
|
|
if !dmsState.exists {
|
|
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"})
|
|
} else {
|
|
status, message := statusOK, dmsState.enabled
|
|
if dmsState.active != "" {
|
|
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
|
|
}
|
|
switch {
|
|
case dmsState.enabled == "disabled":
|
|
status, message = statusWarn, "Disabled"
|
|
case dmsState.active == "failed" || dmsState.active == "inactive":
|
|
status = statusError
|
|
}
|
|
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
|
|
}
|
|
|
|
greetdState := getServiceState("greetd", false)
|
|
switch {
|
|
case greetdState.exists:
|
|
status := statusOK
|
|
if greetdState.enabled == "disabled" {
|
|
status = statusInfo
|
|
}
|
|
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"})
|
|
case doctorVerbose:
|
|
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"})
|
|
}
|
|
|
|
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(r.category.String()))
|
|
currentCategory = r.category
|
|
}
|
|
printResultLine(r, styles)
|
|
}
|
|
}
|
|
|
|
func printResultsJSON(results []checkResult) {
|
|
var ds DoctorStatus
|
|
for _, r := range results {
|
|
ds.Add(r)
|
|
}
|
|
|
|
output := doctorOutputJSON{}
|
|
output.Summary.Errors = ds.ErrorCount()
|
|
output.Summary.Warnings = ds.WarningCount()
|
|
output.Summary.OK = ds.OKCount()
|
|
output.Summary.Info = len(ds.Info)
|
|
|
|
output.Results = make([]checkResultJSON, 0, len(results))
|
|
for _, r := range results {
|
|
output.Results = append(output.Results, r.toJSON())
|
|
}
|
|
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(output); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func printResultLine(r checkResult, styles tui.Styles) {
|
|
icon, style := r.status.IconStyle(styles)
|
|
|
|
name := r.name
|
|
nameLen := len(name)
|
|
|
|
if nameLen > checkNameMaxLength {
|
|
name = name[:checkNameMaxLength-1] + "…"
|
|
nameLen = checkNameMaxLength
|
|
}
|
|
dots := strings.Repeat("·", checkNameMaxLength-nameLen)
|
|
|
|
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))
|
|
}
|
|
|
|
if (r.status == statusError || r.status == statusWarn) && r.url != "" {
|
|
fmt.Printf(" %s\n", styles.Subtle.Render("→ "+r.url))
|
|
}
|
|
}
|
|
|
|
func printSummary(results []checkResult, qsMissingFeatures bool) {
|
|
theme := tui.TerminalTheme()
|
|
styles := tui.NewStyles(theme)
|
|
|
|
var ds DoctorStatus
|
|
for _, r := range results {
|
|
ds.Add(r)
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Printf(" %s\n", styles.Subtle.Render("──────────────────────────────────────"))
|
|
|
|
if !ds.HasIssues() {
|
|
fmt.Printf(" %s\n", styles.Success.Render("✓ All checks passed!"))
|
|
} else {
|
|
var parts []string
|
|
|
|
if ds.ErrorCount() > 0 {
|
|
parts = append(parts, styles.Error.Render(fmt.Sprintf("%d error(s)", ds.ErrorCount())))
|
|
}
|
|
if ds.WarningCount() > 0 {
|
|
parts = append(parts, styles.Warning.Render(fmt.Sprintf("%d warning(s)", ds.WarningCount())))
|
|
}
|
|
parts = append(parts, styles.Success.Render(fmt.Sprintf("%d ok", ds.OKCount())))
|
|
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()
|
|
}
|