mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2025-12-06 05:25:41 -05:00
660 lines
18 KiB
Go
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
|
|
}
|