diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/distros/base.go')
| -rw-r--r-- | raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/distros/base.go | 721 |
1 files changed, 721 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/distros/base.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/distros/base.go new file mode 100644 index 0000000..63555c8 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/distros/base.go @@ -0,0 +1,721 @@ +package distros + +import ( + "bufio" + "context" + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/version" +) + +const ( + forceQuickshellGit = false + forceDMSGit = false +) + +// BaseDistribution provides common functionality for all distributions +type BaseDistribution struct { + logChan chan<- string +} + +// NewBaseDistribution creates a new base distribution +func NewBaseDistribution(logChan chan<- string) *BaseDistribution { + return &BaseDistribution{ + logChan: logChan, + } +} + +// Common helper methods +func (b *BaseDistribution) commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func (b *BaseDistribution) CommandExists(cmd string) bool { + return b.commandExists(cmd) +} + +func (b *BaseDistribution) log(message string) { + if b.logChan != nil { + b.logChan <- message + } +} + +func (b *BaseDistribution) logError(message string, err error) { + errorMsg := fmt.Sprintf("ERROR: %s: %v", message, err) + b.log(errorMsg) +} + +// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings. +// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote. +// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes. +func escapeSingleQuotes(s string) string { + return strings.ReplaceAll(s, "'", "'\\''") +} + +// MakeSudoCommand creates a command string that safely passes password to sudo. +// This helper escapes special characters in the password to prevent shell injection +// and syntax errors when passwords contain single quotes, apostrophes, or other special chars. +func MakeSudoCommand(sudoPassword string, command string) string { + return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command) +} + +// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password. +// The password is properly escaped to prevent shell injection and syntax errors. +func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd { + cmdStr := MakeSudoCommand(sudoPassword, command) + return exec.CommandContext(ctx, "bash", "-c", cmdStr) +} + +func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency { + status := deps.StatusMissing + if b.commandExists(name) { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: name, + Status: status, + Description: description, + Required: true, + } +} + +func (b *BaseDistribution) detectPackage(name, description string, installed bool) deps.Dependency { + status := deps.StatusMissing + if installed { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: name, + Status: status, + Description: description, + Required: true, + } +} + +func (b *BaseDistribution) detectOptionalPackage(name, description string, installed bool) deps.Dependency { + status := deps.StatusMissing + if installed { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: name, + Status: status, + Description: description, + Required: false, + } +} + +func (b *BaseDistribution) detectGit() deps.Dependency { + return b.detectCommand("git", "Version control system") +} + +func (b *BaseDistribution) detectMatugen() deps.Dependency { + return b.detectCommand("matugen", "Material Design color generation tool") +} + +func (b *BaseDistribution) detectDgop() deps.Dependency { + return b.detectCommand("dgop", "Desktop portal management tool") +} + +func (b *BaseDistribution) detectDMS() deps.Dependency { + dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms") + + status := deps.StatusMissing + currentVersion := "" + + if _, err := os.Stat(dmsPath); err == nil { + status = deps.StatusInstalled + + // Only get current version, don't check for updates (lazy loading) + current, err := version.GetCurrentDMSVersion() + if err == nil { + currentVersion = current + } + } + + dep := deps.Dependency{ + Name: "dms (DankMaterialShell)", + Status: status, + Description: "Desktop Management System configuration", + Required: true, + CanToggle: true, + } + + if currentVersion != "" { + dep.Version = currentVersion + } + + return dep +} + +func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.Dependency { + switch terminal { + case deps.TerminalGhostty: + status := deps.StatusMissing + if b.commandExists("ghostty") { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: "ghostty", + Status: status, + Description: "A fast, native terminal emulator built in Zig.", + Required: true, + } + case deps.TerminalKitty: + status := deps.StatusMissing + if b.commandExists("kitty") { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: "kitty", + Status: status, + Description: "A feature-rich, customizable terminal emulator.", + Required: true, + } + case deps.TerminalAlacritty: + status := deps.StatusMissing + if b.commandExists("alacritty") { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: "alacritty", + Status: status, + Description: "A simple terminal emulator. (No dynamic theming)", + Required: true, + } + default: + return b.detectSpecificTerminal(deps.TerminalGhostty) + } +} + +func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency { + var dependencies []deps.Dependency + + tools := []struct { + name string + description string + }{ + {"hyprctl", "Hyprland control utility"}, + {"jq", "JSON processor"}, + } + + for _, tool := range tools { + status := deps.StatusMissing + if b.commandExists(tool.name) { + status = deps.StatusInstalled + } + + dependencies = append(dependencies, deps.Dependency{ + Name: tool.name, + Status: status, + Description: tool.description, + Required: true, + }) + } + + return dependencies +} + +func (b *BaseDistribution) detectQuickshell() deps.Dependency { + if !b.commandExists("qs") { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusMissing, + Description: "QtQuick based desktop shell toolkit", + Required: true, + Variant: deps.VariantStable, + CanToggle: true, + } + } + + cmd := exec.Command("qs", "--version") + output, err := cmd.Output() + if err != nil { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusNeedsReinstall, + Description: "QtQuick based desktop shell toolkit (version check failed)", + Required: true, + Variant: deps.VariantStable, + CanToggle: true, + } + } + + versionStr := string(output) + versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`) + matches := versionRegex.FindStringSubmatch(versionStr) + + if len(matches) < 2 { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusNeedsReinstall, + Description: "QtQuick based desktop shell toolkit (unknown version)", + Required: true, + Variant: deps.VariantStable, + CanToggle: true, + } + } + + version := matches[1] + variant := deps.VariantStable + if strings.Contains(versionStr, "git") || strings.Contains(versionStr, "+") { + variant = deps.VariantGit + } + + if b.versionCompare(version, "0.2.0") >= 0 { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusInstalled, + Version: version, + Description: "QtQuick based desktop shell toolkit", + Required: true, + Variant: variant, + CanToggle: true, + } + } + + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusNeedsUpdate, + Variant: variant, + CanToggle: true, + Version: version, + Description: "QtQuick based desktop shell toolkit (needs 0.2.0+)", + Required: true, + } +} + +func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency { + switch wm { + case deps.WindowManagerHyprland: + status := deps.StatusMissing + variant := deps.VariantStable + version := "" + + if b.commandExists("hyprland") || b.commandExists("Hyprland") { + status = deps.StatusInstalled + cmd := exec.Command("hyprctl", "version") + if output, err := cmd.Output(); err == nil { + outStr := string(output) + if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { + variant = deps.VariantGit + } + if versionRegex := regexp.MustCompile(`v(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) { + matches := versionRegex.FindStringSubmatch(outStr) + if len(matches) > 1 { + version = matches[1] + } + } + } + } + return deps.Dependency{ + Name: "hyprland", + Status: status, + Version: version, + Description: "Dynamic tiling Wayland compositor", + Required: true, + Variant: variant, + CanToggle: true, + } + case deps.WindowManagerNiri: + status := deps.StatusMissing + variant := deps.VariantStable + version := "" + + if b.commandExists("niri") { + status = deps.StatusInstalled + cmd := exec.Command("niri", "--version") + if output, err := cmd.Output(); err == nil { + outStr := string(output) + if strings.Contains(outStr, "git") || strings.Contains(outStr, "+") { + variant = deps.VariantGit + } + if versionRegex := regexp.MustCompile(`niri (\d+\.\d+)`); versionRegex.MatchString(outStr) { + matches := versionRegex.FindStringSubmatch(outStr) + if len(matches) > 1 { + version = matches[1] + } + } + } + } + return deps.Dependency{ + Name: "niri", + Status: status, + Version: version, + Description: "Scrollable-tiling Wayland compositor", + Required: true, + Variant: variant, + CanToggle: true, + } + default: + return deps.Dependency{ + Name: "unknown-wm", + Status: deps.StatusMissing, + Description: "Unknown window manager", + Required: true, + } + } +} + +// Version comparison helper +func (b *BaseDistribution) versionCompare(v1, v2 string) int { + parts1 := strings.Split(v1, ".") + parts2 := strings.Split(v2, ".") + + for i := 0; i < len(parts1) && i < len(parts2); i++ { + if parts1[i] < parts2[i] { + return -1 + } + if parts1[i] > parts2[i] { + return 1 + } + } + + if len(parts1) < len(parts2) { + return -1 + } + if len(parts1) > len(parts2) { + return 1 + } + + return 0 +} + +// Common installation helper +func (b *BaseDistribution) runWithProgress(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64) error { + return b.runWithProgressTimeout(cmd, progressChan, phase, startProgress, endProgress, 20*time.Minute) +} + +func (b *BaseDistribution) runWithProgressTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, timeout time.Duration) error { + return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, "Installing...", timeout) +} + +func (b *BaseDistribution) runWithProgressStep(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string) error { + return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, stepMessage, 20*time.Minute) +} + +func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string, timeoutDuration time.Duration) error { + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return err + } + + outputChan := make(chan string, 100) + done := make(chan error, 1) + + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + b.log(line) + outputChan <- line + } + }() + + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + b.log(line) + outputChan <- line + } + }() + + go func() { + done <- cmd.Wait() + close(outputChan) + }() + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + progress := startProgress + progressStep := (endProgress - startProgress) / 50 + lastOutput := "" + + var timeout *time.Timer + var timeoutChan <-chan time.Time + if timeoutDuration > 0 { + timeout = time.NewTimer(timeoutDuration) + defer timeout.Stop() + timeoutChan = timeout.C + } + + for { + select { + case err := <-done: + if err != nil { + b.logError("Command execution failed", err) + b.log(fmt.Sprintf("Last output before failure: %s", lastOutput)) + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: startProgress, + Step: "Command failed", + IsComplete: false, + LogOutput: lastOutput, + Error: err, + } + return err + } + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: endProgress, + Step: "Installation step complete", + IsComplete: false, + LogOutput: lastOutput, + } + return nil + case output, ok := <-outputChan: + if ok { + lastOutput = output + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: progress, + Step: stepMessage, + IsComplete: false, + LogOutput: output, + } + if timeout != nil { + timeout.Reset(timeoutDuration) + } + } + case <-timeoutChan: + if cmd.Process != nil { + cmd.Process.Kill() + } + err := fmt.Errorf("installation timed out after %v", timeoutDuration) + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: startProgress, + Step: "Installation timed out", + IsComplete: false, + LogOutput: lastOutput, + Error: err, + } + return err + case <-ticker.C: + if progress < endProgress-0.01 { + progress += progressStep + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: progress, + Step: "Installing...", + IsComplete: false, + LogOutput: lastOutput, + } + } + } + } +} + +func (b *BaseDistribution) DetectTerminalFromDeps(dependencies []deps.Dependency) deps.Terminal { + for _, dep := range dependencies { + switch dep.Name { + case "ghostty": + return deps.TerminalGhostty + case "kitty": + return deps.TerminalKitty + case "alacritty": + return deps.TerminalAlacritty + } + } + return deps.TerminalGhostty +} + +func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + envDir := filepath.Join(homeDir, ".config", "environment.d") + if err := os.MkdirAll(envDir, 0o755); err != nil { + return fmt.Errorf("failed to create environment.d directory: %w", err) + } + + var terminalCmd string + switch terminal { + case deps.TerminalGhostty: + terminalCmd = "ghostty" + case deps.TerminalKitty: + terminalCmd = "kitty" + case deps.TerminalAlacritty: + terminalCmd = "alacritty" + default: + terminalCmd = "ghostty" + } + + content := fmt.Sprintf(`ELECTRON_OZONE_PLATFORM_HINT=auto +TERMINAL=%s +`, terminalCmd) + + envFile := filepath.Join(envDir, "90-dms.conf") + if err := os.WriteFile(envFile, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write environment config: %w", err) + } + + b.log(fmt.Sprintf("Wrote environment config to %s", envFile)) + return nil +} + +func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) error { + switch wm { + case deps.WindowManagerNiri: + if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").Run(); err != nil { + b.log("Warning: failed to add dms as a want for niri.service") + } + case deps.WindowManagerHyprland: + if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").Run(); err != nil { + b.log("Warning: failed to add dms as a want for hyprland-session.target") + } + } + + return nil +} + +func (b *BaseDistribution) WriteWindowManagerConfig(wm deps.WindowManager) error { + if wm == deps.WindowManagerHyprland { + if err := b.WriteHyprlandSessionTarget(); err != nil { + return fmt.Errorf("failed to write hyprland session target: %w", err) + } + } + return nil +} + +func (b *BaseDistribution) WriteHyprlandSessionTarget() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + targetDir := filepath.Join(homeDir, ".config", "systemd", "user") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("failed to create systemd user directory: %w", err) + } + + targetPath := filepath.Join(targetDir, "hyprland-session.target") + content := `[Unit] +Description=Hyprland Session Target +Requires=graphical-session.target +After=graphical-session.target +` + + if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write hyprland-session.target: %w", err) + } + + b.log(fmt.Sprintf("Wrote hyprland-session.target to %s", targetPath)) + return nil +} + +// installDMSBinary installs the DMS binary from GitHub releases +func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + b.log("Installing/updating DMS binary...") + + // Detect architecture + arch := runtime.GOARCH + switch arch { + case "amd64": + case "arm64": + default: + return fmt.Errorf("unsupported architecture for DMS: %s", arch) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.80, + Step: "Downloading DMS binary...", + IsComplete: false, + CommandInfo: fmt.Sprintf("Downloading dms-%s.gz", arch), + } + + // Get latest release version + latestVersionCmd := exec.CommandContext(ctx, "bash", "-c", + `curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'`) + versionOutput, err := latestVersionCmd.Output() + if err != nil { + return fmt.Errorf("failed to get latest DMS version: %w", err) + } + version := strings.TrimSpace(string(versionOutput)) + if version == "" { + return fmt.Errorf("could not determine latest DMS version") + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Download the gzipped binary + downloadURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch) + gzPath := filepath.Join(tmpDir, "dms.gz") + + downloadCmd := exec.CommandContext(ctx, "curl", "-L", downloadURL, "-o", gzPath) + if err := downloadCmd.Run(); err != nil { + return fmt.Errorf("failed to download DMS binary: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.85, + Step: "Extracting DMS binary...", + IsComplete: false, + CommandInfo: "gunzip dms.gz", + } + + // Extract the binary + extractCmd := exec.CommandContext(ctx, "gunzip", gzPath) + if err := extractCmd.Run(); err != nil { + return fmt.Errorf("failed to extract DMS binary: %w", err) + } + + binaryPath := filepath.Join(tmpDir, "dms") + + // Make it executable + chmodCmd := exec.CommandContext(ctx, "chmod", "+x", binaryPath) + if err := chmodCmd.Run(); err != nil { + return fmt.Errorf("failed to make DMS binary executable: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.88, + Step: "Installing DMS to /usr/local/bin...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo cp dms /usr/local/bin/", + } + + // Install to /usr/local/bin + installCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath)) + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install DMS binary: %w", err) + } + + b.log("DMS binary installed successfully") + return nil +} |