1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00
Files
2025-11-12 23:12:31 -05:00

660 lines
18 KiB
Go

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
const 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)
}
// Common dependency detection methods
func (b *BaseDistribution) detectGit() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("git") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "git",
Status: status,
Description: "Version control system",
Required: true,
}
}
func (b *BaseDistribution) detectMatugen() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("matugen") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "matugen",
Status: status,
Description: "Material Design color generation tool",
Required: true,
}
}
func (b *BaseDistribution) detectDgop() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("dgop") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dgop",
Status: status,
Description: "Desktop portal management tool",
Required: true,
}
}
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) detectClipboardTools() []deps.Dependency {
var dependencies []deps.Dependency
cliphist := deps.StatusMissing
if b.commandExists("cliphist") {
cliphist = deps.StatusInstalled
}
wlClipboard := deps.StatusMissing
if b.commandExists("wl-copy") && b.commandExists("wl-paste") {
wlClipboard = deps.StatusInstalled
}
dependencies = append(dependencies,
deps.Dependency{
Name: "cliphist",
Status: cliphist,
Description: "Wayland clipboard manager",
Required: true,
},
deps.Dependency{
Name: "wl-clipboard",
Status: wlClipboard,
Description: "Wayland clipboard utilities",
Required: true,
},
)
return dependencies
}
func (b *BaseDistribution) detectHyprpicker() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("hyprpicker") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "hyprpicker",
Status: status,
Description: "Color picker for Wayland",
Required: true,
}
}
func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency {
var dependencies []deps.Dependency
tools := []struct {
name string
description string
}{
{"grim", "Screenshot utility for Wayland"},
{"slurp", "Region selection utility for Wayland"},
{"hyprctl", "Hyprland control utility"},
{"grimblast", "Screenshot script for Hyprland"},
{"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,
}
}
}
}
}
// 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, 0755); 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
}