diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms')
34 files changed, 10521 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/assets/cli-policy.default.json b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/assets/cli-policy.default.json new file mode 100644 index 0000000..de9b8ad --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/assets/cli-policy.default.json @@ -0,0 +1,10 @@ +{ + "policy_version": 1, + "blocked_commands": [ + "greeter install", + "greeter enable", + "greeter uninstall", + "setup" + ], + "message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes." +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_auth.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_auth.go new file mode 100644 index 0000000..727700e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_auth.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/spf13/cobra" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage DMS authentication sync", + Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen", +} + +var authSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync DMS authentication configuration", + Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings", + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + if err := syncAuthInTerminal(yes); err != nil { + log.Fatalf("Error launching auth sync in terminal: %v", err) + } + return + } + if err := syncAuth(yes); err != nil { + log.Fatalf("Error syncing authentication: %v", err) + } + }, +} + +func init() { + authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts") + authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)") +} + +func syncAuth(nonInteractive bool) error { + if !nonInteractive { + fmt.Println("=== DMS Authentication Sync ===") + fmt.Println() + } + + logFunc := func(msg string) { + fmt.Println(msg) + } + + if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil { + return err + } + + if !nonInteractive { + fmt.Println("\n=== Authentication Sync Complete ===") + fmt.Println("\nAuthentication changes have been applied.") + } + + return nil +} + +func syncAuthInTerminal(nonInteractive bool) error { + syncFlags := make([]string, 0, 1) + if nonInteractive { + syncFlags = append(syncFlags, "--yes") + } + + shellSyncCmd := "dms auth sync" + if len(syncFlags) > 0 { + shellSyncCmd += " " + strings.Join(syncFlags, " ") + } + shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3` + return runCommandInTerminal(shellCmd) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_blur.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_blur.go new file mode 100644 index 0000000..d01eb93 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_blur.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/blur" + "github.com/spf13/cobra" +) + +var blurCmd = &cobra.Command{ + Use: "blur", + Short: "Background blur utilities", +} + +var blurCheckCmd = &cobra.Command{ + Use: "check", + Short: "Check if the compositor supports background blur (ext-background-effect-v1)", + Args: cobra.NoArgs, + Run: runBlurCheck, +} + +func init() { + blurCmd.AddCommand(blurCheckCmd) +} + +func runBlurCheck(cmd *cobra.Command, args []string) { + supported, err := blur.ProbeSupport() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + switch supported { + case true: + fmt.Println("supported") + default: + fmt.Println("unsupported") + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_brightness.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_brightness.go new file mode 100644 index 0000000..0f73493 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_brightness.go @@ -0,0 +1,311 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/spf13/cobra" +) + +var brightnessCmd = &cobra.Command{ + Use: "brightness", + Short: "Control device brightness", + Long: "Control brightness for backlight and LED devices (use --ddc to include DDC/I2C monitors)", +} + +var brightnessListCmd = &cobra.Command{ + Use: "list", + Short: "List all brightness devices", + Long: "List all available brightness devices with their current values", + Run: runBrightnessList, +} + +var brightnessSetCmd = &cobra.Command{ + Use: "set <device_id> <percent>", + Short: "Set brightness for a device", + Long: "Set brightness percentage (0-100) for a specific device", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + includeDDC, _ := cmd.Flags().GetBool("ddc") + return getBrightnessDevices(includeDDC), cobra.ShellCompDirectiveNoFileComp + }, + Run: runBrightnessSet, +} + +var brightnessGetCmd = &cobra.Command{ + Use: "get <device_id>", + Short: "Get brightness for a device", + Long: "Get current brightness percentage for a specific device", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + includeDDC, _ := cmd.Flags().GetBool("ddc") + return getBrightnessDevices(includeDDC), cobra.ShellCompDirectiveNoFileComp + }, + Run: runBrightnessGet, +} + +func init() { + brightnessListCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)") + brightnessSetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)") + brightnessSetCmd.Flags().Bool("exponential", false, "Use exponential brightness scaling") + brightnessSetCmd.Flags().Float64("exponent", 1.2, "Exponent for exponential scaling (default 1.2)") + brightnessGetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)") + + brightnessCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`) + + brightnessListCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +`) + + brightnessSetCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +`) + + brightnessGetCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +`) + + brightnessCmd.AddCommand(brightnessListCmd, brightnessSetCmd, brightnessGetCmd) +} + +func getAllBrightnessDevices(includeDDC bool) []brightness.Device { + allDevices := []brightness.Device{} + + sysfs, err := brightness.NewSysfsBackend() + if err != nil { + log.Debugf("Failed to initialize sysfs backend: %v", err) + } else { + devices, err := sysfs.GetDevices() + if err != nil { + log.Debugf("Failed to get sysfs devices: %v", err) + } else { + allDevices = append(allDevices, devices...) + } + } + + if includeDDC { + ddc, err := brightness.NewDDCBackend() + if err != nil { + fmt.Printf("Warning: Failed to initialize DDC backend: %v\n", err) + } else { + time.Sleep(100 * time.Millisecond) + devices, err := ddc.GetDevices() + if err != nil { + fmt.Printf("Warning: Failed to get DDC devices: %v\n", err) + } else { + allDevices = append(allDevices, devices...) + } + ddc.Close() + } + } + + return allDevices +} + +func runBrightnessList(cmd *cobra.Command, args []string) { + includeDDC, _ := cmd.Flags().GetBool("ddc") + allDevices := getAllBrightnessDevices(includeDDC) + + if len(allDevices) == 0 { + fmt.Println("No brightness devices found") + return + } + + maxIDLen := len("Device") + maxNameLen := len("Name") + for _, dev := range allDevices { + if len(dev.ID) > maxIDLen { + maxIDLen = len(dev.ID) + } + if len(dev.Name) > maxNameLen { + maxNameLen = len(dev.Name) + } + } + + idPad := maxIDLen + 2 + namePad := maxNameLen + 2 + + fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness") + + sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15 + for range sepLen { + fmt.Print("─") + } + fmt.Println() + + for _, device := range allDevices { + fmt.Printf("%-*s %-12s %-*s %3d%%\n", + idPad, + device.ID, + device.Class, + namePad, + device.Name, + device.CurrentPercent, + ) + } +} + +func runBrightnessSet(cmd *cobra.Command, args []string) { + deviceID := args[0] + var percent int + if _, err := fmt.Sscanf(args[1], "%d", &percent); err != nil { + log.Fatalf("Invalid percent value: %s", args[1]) + } + + if percent < 0 || percent > 100 { + log.Fatalf("Percent must be between 0 and 100") + } + + includeDDC, _ := cmd.Flags().GetBool("ddc") + exponential, _ := cmd.Flags().GetBool("exponential") + exponent, _ := cmd.Flags().GetFloat64("exponent") + + parts := strings.SplitN(deviceID, ":", 2) + if len(parts) == 2 && (parts[0] == "backlight" || parts[0] == "leds") { + if ok := tryLogindBrightness(parts[0], parts[1], deviceID, percent, exponential, exponent); ok { + return + } + } + + sysfs, err := brightness.NewSysfsBackend() + if err == nil { + if err := sysfs.SetBrightnessWithExponent(deviceID, percent, exponential, exponent); err == nil { + fmt.Printf("Set %s to %d%%\n", deviceID, percent) + return + } + log.Debugf("sysfs.SetBrightness failed: %v", err) + } else { + log.Debugf("NewSysfsBackend failed: %v", err) + } + + // Try DDC if requested + if includeDDC { + ddc, err := brightness.NewDDCBackend() + if err == nil { + defer ddc.Close() + time.Sleep(100 * time.Millisecond) + if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil { + ddc.WaitPending() + fmt.Printf("Set %s to %d%%\n", deviceID, percent) + return + } + log.Debugf("ddc.SetBrightness failed: %v", err) + } else { + log.Debugf("NewDDCBackend failed: %v", err) + } + } + + log.Fatalf("Failed to set brightness for device: %s", deviceID) +} + +func tryLogindBrightness(subsystem, name, deviceID string, percent int, exponential bool, exponent float64) bool { + sysfs, err := brightness.NewSysfsBackend() + if err != nil { + log.Debugf("NewSysfsBackend failed: %v", err) + return false + } + + logind, err := brightness.NewLogindBackend() + if err != nil { + log.Debugf("NewLogindBackend failed: %v", err) + return false + } + defer logind.Close() + + dev, err := sysfs.GetDevice(deviceID) + if err != nil { + log.Debugf("sysfs.GetDeviceByID failed: %v", err) + return false + } + + value := sysfs.PercentToValueWithExponent(percent, dev, exponential, exponent) + if err := logind.SetBrightness(subsystem, name, uint32(value)); err != nil { + log.Debugf("logind.SetBrightness failed: %v", err) + return false + } + + log.Debugf("set %s to %d%% (%d) via logind", deviceID, percent, value) + fmt.Printf("Set %s to %d%%\n", deviceID, percent) + return true +} + +func getBrightnessDevices(includeDDC bool) []string { + allDevices := getAllBrightnessDevices(includeDDC) + + var deviceIDs []string + for _, device := range allDevices { + deviceIDs = append(deviceIDs, device.ID) + } + return deviceIDs +} + +func runBrightnessGet(cmd *cobra.Command, args []string) { + deviceID := args[0] + includeDDC, _ := cmd.Flags().GetBool("ddc") + allDevices := getAllBrightnessDevices(includeDDC) + + for _, device := range allDevices { + if device.ID == deviceID { + fmt.Printf("%s: %d%% (%d/%d)\n", + device.ID, + device.CurrentPercent, + device.Current, + device.Max, + ) + return + } + } + + log.Fatalf("Device not found: %s", deviceID) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_chroma.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_chroma.go new file mode 100644 index 0000000..4945f33 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_chroma.go @@ -0,0 +1,300 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/spf13/cobra" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + ghtml "github.com/yuin/goldmark/renderer/html" +) + +var ( + chromaLanguage string + chromaStyle string + chromaInline bool + chromaMarkdown bool + chromaLineNumbers bool + + // Caching layer for performance + lexerCache = make(map[string]chroma.Lexer) + styleCache = make(map[string]*chroma.Style) + formatterCache = make(map[string]*html.Formatter) + cacheMutex sync.RWMutex + maxFileSize = int64(5 * 1024 * 1024) // 5MB default +) + +var chromaCmd = &cobra.Command{ + Use: "chroma [file]", + Short: "Syntax highlight source code", + Long: `Generate syntax-highlighted HTML from source code. + +Reads from file or stdin, outputs HTML with syntax highlighting. +Language is auto-detected from filename or can be specified with --language. + +Examples: + dms chroma main.go + dms chroma --language python script.py + echo "def foo(): pass" | dms chroma -l python + cat code.rs | dms chroma -l rust --style dracula + dms chroma --markdown README.md + dms chroma --markdown --style github-dark notes.md + dms chroma list-languages + dms chroma list-styles`, + Args: cobra.MaximumNArgs(1), + Run: runChroma, +} + +var chromaListLanguagesCmd = &cobra.Command{ + Use: "list-languages", + Short: "List all supported languages", + Run: func(cmd *cobra.Command, args []string) { + for _, name := range lexers.Names(true) { + fmt.Println(name) + } + }, +} + +var chromaListStylesCmd = &cobra.Command{ + Use: "list-styles", + Short: "List all available color styles", + Run: func(cmd *cobra.Command, args []string) { + for _, name := range styles.Names() { + fmt.Println(name) + } + }, +} + +func init() { + chromaCmd.Flags().StringVarP(&chromaLanguage, "language", "l", "", "Language for highlighting (auto-detect if not specified)") + chromaCmd.Flags().StringVarP(&chromaStyle, "style", "s", "monokai", "Color style (monokai, dracula, github, etc.)") + chromaCmd.Flags().BoolVar(&chromaInline, "inline", false, "Output inline styles instead of CSS classes") + chromaCmd.Flags().BoolVar(&chromaLineNumbers, "line-numbers", false, "Show line numbers in output") + chromaCmd.Flags().BoolVarP(&chromaMarkdown, "markdown", "m", false, "Render markdown with syntax-highlighted code blocks") + chromaCmd.Flags().Int64Var(&maxFileSize, "max-size", 5*1024*1024, "Maximum file size to process without warning (bytes)") + + chromaCmd.AddCommand(chromaListLanguagesCmd) + chromaCmd.AddCommand(chromaListStylesCmd) +} + +func getCachedLexer(key string, fallbackFunc func() chroma.Lexer) chroma.Lexer { + cacheMutex.RLock() + if lexer, ok := lexerCache[key]; ok { + cacheMutex.RUnlock() + return lexer + } + cacheMutex.RUnlock() + + lexer := fallbackFunc() + if lexer != nil { + cacheMutex.Lock() + lexerCache[key] = lexer + cacheMutex.Unlock() + } + return lexer +} + +func getCachedStyle(name string) *chroma.Style { + cacheMutex.RLock() + if style, ok := styleCache[name]; ok { + cacheMutex.RUnlock() + return style + } + cacheMutex.RUnlock() + + style := styles.Get(name) + if style == nil { + fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name) + style = styles.Fallback + } + + cacheMutex.Lock() + styleCache[name] = style + cacheMutex.Unlock() + return style +} + +func getCachedFormatter(inline bool, lineNumbers bool) *html.Formatter { + key := fmt.Sprintf("inline=%t,lineNumbers=%t", inline, lineNumbers) + + cacheMutex.RLock() + if formatter, ok := formatterCache[key]; ok { + cacheMutex.RUnlock() + return formatter + } + cacheMutex.RUnlock() + + var opts []html.Option + if inline { + opts = append(opts, html.WithClasses(false)) + } else { + opts = append(opts, html.WithClasses(true)) + } + opts = append(opts, html.TabWidth(4)) + + if lineNumbers { + opts = append(opts, html.WithLineNumbers(true)) + opts = append(opts, html.LineNumbersInTable(false)) + opts = append(opts, html.WithLinkableLineNumbers(false, "")) + } + + formatter := html.New(opts...) + + cacheMutex.Lock() + formatterCache[key] = formatter + cacheMutex.Unlock() + return formatter +} + +func runChroma(cmd *cobra.Command, args []string) { + var source string + var filename string + + // Read from file or stdin + if len(args) > 0 { + filename = args[0] + + // Check file size before reading + fileInfo, err := os.Stat(filename) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file info: %v\n", err) + os.Exit(1) + } + + if fileInfo.Size() > maxFileSize { + fmt.Fprintf(os.Stderr, "Warning: File size (%d bytes) exceeds recommended limit (%d bytes)\n", + fileInfo.Size(), maxFileSize) + fmt.Fprintf(os.Stderr, "Processing may be slow. Consider using smaller files.\n") + } + + content, err := os.ReadFile(filename) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + source = string(content) + } else { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + _ = cmd.Help() + os.Exit(0) + } + + content, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err) + os.Exit(1) + } + source = string(content) + } + + // Handle empty input + if strings.TrimSpace(source) == "" { + return + } + + // Handle Markdown rendering + if chromaMarkdown { + md := goldmark.New( + goldmark.WithExtensions( + extension.GFM, + highlighting.NewHighlighting( + highlighting.WithStyle(chromaStyle), + highlighting.WithFormatOptions( + html.WithClasses(!chromaInline), + ), + ), + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + ghtml.WithHardWraps(), + ghtml.WithXHTML(), + ), + ) + + var buf bytes.Buffer + if err := md.Convert([]byte(source), &buf); err != nil { + fmt.Fprintf(os.Stderr, "Markdown rendering error: %v\n", err) + os.Exit(1) + } + fmt.Print(buf.String()) + return + } + + // Detect or use specified lexer + var lexer chroma.Lexer + if chromaLanguage != "" { + lexer = getCachedLexer(chromaLanguage, func() chroma.Lexer { + l := lexers.Get(chromaLanguage) + if l == nil { + fmt.Fprintf(os.Stderr, "Unknown language: %s\n", chromaLanguage) + os.Exit(1) + } + return l + }) + } else if filename != "" { + lexer = getCachedLexer("file:"+filename, func() chroma.Lexer { + return lexers.Match(filename) + }) + } + + // Try content analysis if no lexer found (limit to first 1KB for performance) + if lexer == nil { + analyzeContent := source + if len(source) > 1024 { + analyzeContent = source[:1024] + } + lexer = lexers.Analyse(analyzeContent) + } + + // Fallback to plaintext + if lexer == nil { + lexer = lexers.Fallback + } + + lexer = chroma.Coalesce(lexer) + + // Get cached style + style := getCachedStyle(chromaStyle) + + // Get cached formatter + formatter := getCachedFormatter(chromaInline, chromaLineNumbers) + + // Tokenize + iterator, err := lexer.Tokenise(nil, source) + if err != nil { + fmt.Fprintf(os.Stderr, "Tokenization error: %v\n", err) + os.Exit(1) + } + + // Format and output + if chromaLineNumbers { + var buf bytes.Buffer + if err := formatter.Format(&buf, style, iterator); err != nil { + fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err) + os.Exit(1) + } + // Add spacing between line numbers + output := buf.String() + output = strings.ReplaceAll(output, "</span><span>", "</span>\u00A0\u00A0<span>") + fmt.Print(output) + } else { + if err := formatter.Format(os.Stdout, style, iterator); err != nil { + fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err) + os.Exit(1) + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_clipboard.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_clipboard.go new file mode 100644 index 0000000..8a9b858 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_clipboard.go @@ -0,0 +1,1004 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + bolt "go.etcd.io/bbolt" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/spf13/cobra" +) + +var clipboardCmd = &cobra.Command{ + Use: "clipboard", + Aliases: []string{"cl"}, + Short: "Manage clipboard", + Long: "Interact with the clipboard manager", +} + +var clipCopyCmd = &cobra.Command{ + Use: "copy [text]", + Short: "Copy text to clipboard", + Long: "Copy text to clipboard. If no text provided, reads from stdin. Works without server.", + Run: runClipCopy, +} + +var ( + clipCopyForeground bool + clipCopyPasteOnce bool + clipCopyType string + clipCopyDownload bool + clipJSONOutput bool +) + +var clipPasteCmd = &cobra.Command{ + Use: "paste", + Short: "Paste text from clipboard", + Long: "Paste text from clipboard to stdout. Works without server.", + Run: runClipPaste, +} + +var clipWatchCmd = &cobra.Command{ + Use: "watch [command]", + Short: "Watch clipboard for changes", + Long: `Watch clipboard for changes and optionally execute a command. +Works like wl-paste --watch. Does not require server. + +If a command is provided, it will be executed each time the clipboard changes, +with the clipboard content piped to its stdin. + +Examples: + dms cl watch # Print clipboard changes to stdout + dms cl watch cat # Same as above + dms cl watch notify-send # Send notification on clipboard change`, + Run: runClipWatch, +} + +var clipHistoryCmd = &cobra.Command{ + Use: "history", + Short: "Show clipboard history", + Long: "Show clipboard history with previews (requires server)", + Run: runClipHistory, +} + +var clipGetCmd = &cobra.Command{ + Use: "get <id>", + Short: "Get clipboard entry by ID", + Long: "Get full clipboard entry data by ID (requires server). Use --copy to copy it to clipboard.", + Args: cobra.ExactArgs(1), + Run: runClipGet, +} + +var clipGetCopy bool + +var clipDeleteCmd = &cobra.Command{ + Use: "delete <id>", + Short: "Delete clipboard entry", + Long: "Delete a clipboard history entry by ID (requires server)", + Args: cobra.ExactArgs(1), + Run: runClipDelete, +} + +var clipClearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear clipboard history", + Long: "Clear all clipboard history (requires server)", + Run: runClipClear, +} + +var clipWatchStore bool +var clipWatchMimes bool + +var clipSearchCmd = &cobra.Command{ + Use: "search [query]", + Short: "Search clipboard history", + Long: "Search clipboard history with filters (requires server)", + Run: runClipSearch, +} + +var ( + clipSearchLimit int + clipSearchOffset int + clipSearchMimeType string + clipSearchImages bool + clipSearchText bool +) + +var clipConfigCmd = &cobra.Command{ + Use: "config", + Short: "Manage clipboard config", + Long: "Get or set clipboard configuration (requires server)", +} + +var clipConfigGetCmd = &cobra.Command{ + Use: "get", + Short: "Get clipboard config", + Run: runClipConfigGet, +} + +var clipConfigSetCmd = &cobra.Command{ + Use: "set", + Short: "Set clipboard config", + Long: `Set clipboard configuration options. + +Examples: + dms cl config set --max-history 200 + dms cl config set --auto-clear-days 7 + dms cl config set --clear-at-startup`, + Run: runClipConfigSet, +} + +var ( + clipConfigMaxHistory int + clipConfigAutoClearDays int + clipConfigClearAtStartup bool + clipConfigNoClearStartup bool + clipConfigDisabled bool + clipConfigEnabled bool +) + +var clipExportCmd = &cobra.Command{ + Use: "export [file]", + Short: "Export clipboard history to JSON", + Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.", + Run: runClipExport, +} + +var clipImportCmd = &cobra.Command{ + Use: "import <file>", + Short: "Import clipboard history from JSON", + Long: "Import clipboard history from JSON file exported by 'dms cl export'.", + Args: cobra.ExactArgs(1), + Run: runClipImport, +} + +var clipMigrateCmd = &cobra.Command{ + Use: "cliphist-migrate [db-path]", + Short: "Migrate from cliphist", + Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.", + Run: runClipMigrate, +} + +var clipMigrateDelete bool + +func init() { + clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") + clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") + clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type") + clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file") + + clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "C", false, "Copy entry to clipboard") + + clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results") + clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset") + clipSearchCmd.Flags().StringVarP(&clipSearchMimeType, "mime", "m", "", "Filter by MIME type") + clipSearchCmd.Flags().BoolVar(&clipSearchImages, "images", false, "Only images") + clipSearchCmd.Flags().BoolVar(&clipSearchText, "text", false, "Only text") + clipSearchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + + clipConfigSetCmd.Flags().IntVar(&clipConfigMaxHistory, "max-history", 0, "Max history entries") + clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)") + clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup") + clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup") + clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard tracking") + clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking") + + clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)") + clipWatchCmd.Flags().BoolVarP(&clipWatchMimes, "mimes", "m", false, "Show all offered MIME types") + + clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration") + + clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) + clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd) +} + +func runClipCopy(cmd *cobra.Command, args []string) { + var data []byte + copyFromStdin := false + + switch { + case len(args) > 0: + data = []byte(args[0]) + case clipCopyDownload || clipCopyType == "__multi__": + var err error + data, err = io.ReadAll(os.Stdin) + if err != nil { + log.Fatalf("read stdin: %v", err) + } + default: + copyFromStdin = true + } + + if clipCopyDownload { + filePath, err := downloadToTempFile(strings.TrimSpace(string(data))) + if err != nil { + log.Fatalf("download: %v", err) + } + if err := copyFileToClipboard(filePath); err != nil { + log.Fatalf("copy file: %v", err) + } + fmt.Printf("Downloaded and copied: %s\n", filePath) + return + } + + if clipCopyType == "__multi__" { + offers, err := parseMultiOffers(data) + if err != nil { + log.Fatalf("parse multi offers: %v", err) + } + if err := clipboard.CopyMulti(offers, true, clipCopyPasteOnce); err != nil { + log.Fatalf("copy multi: %v", err) + } + return + } + + if copyFromStdin { + if err := clipboard.CopyReader(os.Stdin, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { + log.Fatalf("copy: %v", err) + } + return + } + + if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { + log.Fatalf("copy: %v", err) + } +} + +func parseMultiOffers(data []byte) ([]clipboard.Offer, error) { + var offers []clipboard.Offer + pos := 0 + + for pos < len(data) { + mimeEnd := bytes.IndexByte(data[pos:], 0) + if mimeEnd == -1 { + break + } + mimeType := string(data[pos : pos+mimeEnd]) + pos += mimeEnd + 1 + + lenEnd := bytes.IndexByte(data[pos:], 0) + if lenEnd == -1 { + break + } + dataLen, err := strconv.Atoi(string(data[pos : pos+lenEnd])) + if err != nil { + return nil, fmt.Errorf("parse length: %w", err) + } + pos += lenEnd + 1 + + if pos+dataLen > len(data) { + return nil, fmt.Errorf("data truncated") + } + offerData := data[pos : pos+dataLen] + pos += dataLen + + offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData}) + } + + return offers, nil +} + +func runClipPaste(cmd *cobra.Command, args []string) { + data, _, err := clipboard.Paste() + if err != nil { + log.Fatalf("paste: %v", err) + } + os.Stdout.Write(data) +} + +func runClipWatch(cmd *cobra.Command, args []string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + switch { + case len(args) > 0: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + runCommand(args, data) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipWatchStore: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + if err := clipboard.Store(data, mimeType); err != nil { + log.Errorf("store: %v", err) + } + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipWatchMimes: + if err := clipboard.WatchAll(ctx, func(data []byte, mimeType string, allMimes []string) { + if clipJSONOutput { + out := map[string]any{ + "data": string(data), + "mimeType": mimeType, + "mimeTypes": allMimes, + "timestamp": time.Now().Format(time.RFC3339), + "size": len(data), + } + j, _ := json.Marshal(out) + fmt.Println(string(j)) + return + } + fmt.Printf("=== Clipboard Change ===\n") + fmt.Printf("Selected: %s\n", mimeType) + fmt.Printf("All MIME types:\n") + for _, m := range allMimes { + fmt.Printf(" - %s\n", m) + } + fmt.Printf("Size: %d bytes\n\n", len(data)) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipJSONOutput: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + out := map[string]any{ + "data": string(data), + "mimeType": mimeType, + "timestamp": time.Now().Format(time.RFC3339), + "size": len(data), + } + j, _ := json.Marshal(out) + fmt.Println(string(j)) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + default: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + os.Stdout.Write(data) + os.Stdout.WriteString("\n") + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + } +} + +func runCommand(args []string, stdin []byte) { + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if len(stdin) == 0 { + cmd.Run() + return + } + + r, w, err := os.Pipe() + if err != nil { + cmd.Run() + return + } + + cmd.Stdin = r + go func() { + w.Write(stdin) + w.Close() + }() + cmd.Run() +} + +func runClipHistory(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.getHistory", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard history: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + if clipJSONOutput { + fmt.Println("[]") + } else { + fmt.Println("No clipboard history") + } + return + } + + historyList, ok := (*resp.Result).([]any) + if !ok { + log.Fatal("Invalid response format") + } + + if clipJSONOutput { + out, _ := json.MarshalIndent(historyList, "", " ") + fmt.Println(string(out)) + return + } + + if len(historyList) == 0 { + fmt.Println("No clipboard history") + return + } + + fmt.Println("Clipboard History:") + fmt.Println() + + for _, item := range historyList { + entry, ok := item.(map[string]any) + if !ok { + continue + } + + id := uint64(entry["id"].(float64)) + preview := entry["preview"].(string) + timestamp := entry["timestamp"].(string) + isImage := entry["isImage"].(bool) + + typeStr := "text" + if isImage { + typeStr = "image" + } + + fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp) + fmt.Printf(" %s\n", preview) + fmt.Println() + } +} + +func runClipGet(cmd *cobra.Command, args []string) { + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + log.Fatalf("Invalid ID: %v", err) + } + + if clipGetCopy { + req := models.Request{ + ID: 1, + Method: "clipboard.copyEntry", + Params: map[string]any{"id": id}, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to copy clipboard entry: %v", err) + } + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Printf("Copied entry %d to clipboard\n", id) + return + } + + req := models.Request{ + ID: 1, + Method: "clipboard.getEntry", + Params: map[string]any{ + "id": id, + }, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard entry: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + log.Fatal("Entry not found") + } + + entry, ok := (*resp.Result).(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + switch { + case clipJSONOutput: + output, _ := json.MarshalIndent(entry, "", " ") + fmt.Println(string(output)) + default: + if data, ok := entry["data"].(string); ok { + fmt.Print(data) + } else { + output, _ := json.MarshalIndent(entry, "", " ") + fmt.Println(string(output)) + } + } +} + +func runClipDelete(cmd *cobra.Command, args []string) { + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + log.Fatalf("Invalid ID: %v", err) + } + + req := models.Request{ + ID: 1, + Method: "clipboard.deleteEntry", + Params: map[string]any{ + "id": id, + }, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to delete clipboard entry: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Printf("Deleted entry %d\n", id) +} + +func runClipClear(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.clearHistory", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to clear clipboard history: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Println("Clipboard history cleared") +} + +func runClipSearch(cmd *cobra.Command, args []string) { + params := map[string]any{ + "limit": clipSearchLimit, + "offset": clipSearchOffset, + } + + if len(args) > 0 { + params["query"] = args[0] + } + if clipSearchMimeType != "" { + params["mimeType"] = clipSearchMimeType + } + if clipSearchImages { + params["isImage"] = true + } else if clipSearchText { + params["isImage"] = false + } + + req := models.Request{ + ID: 1, + Method: "clipboard.search", + Params: params, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to search clipboard: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + log.Fatal("No results") + } + + result, ok := (*resp.Result).(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + if clipJSONOutput { + out, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(out)) + return + } + + entries, _ := result["entries"].([]any) + total := int(result["total"].(float64)) + hasMore := result["hasMore"].(bool) + + if len(entries) == 0 { + fmt.Println("No results found") + return + } + + fmt.Printf("Results: %d of %d\n\n", len(entries), total) + + for _, item := range entries { + entry, ok := item.(map[string]any) + if !ok { + continue + } + + id := uint64(entry["id"].(float64)) + preview := entry["preview"].(string) + timestamp := entry["timestamp"].(string) + isImage := entry["isImage"].(bool) + + typeStr := "text" + if isImage { + typeStr = "image" + } + + fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp) + fmt.Printf(" %s\n\n", preview) + } + + if hasMore { + fmt.Printf("Use --offset %d to see more results\n", clipSearchOffset+clipSearchLimit) + } +} + +func runClipConfigGet(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.getConfig", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get config: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + log.Fatal("No config returned") + } + + cfg, ok := (*resp.Result).(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + output, _ := json.MarshalIndent(cfg, "", " ") + fmt.Println(string(output)) +} + +func runClipConfigSet(cmd *cobra.Command, args []string) { + params := map[string]any{} + + if cmd.Flags().Changed("max-history") { + params["maxHistory"] = clipConfigMaxHistory + } + if cmd.Flags().Changed("auto-clear-days") { + params["autoClearDays"] = clipConfigAutoClearDays + } + if clipConfigClearAtStartup { + params["clearAtStartup"] = true + } + if clipConfigNoClearStartup { + params["clearAtStartup"] = false + } + if clipConfigDisabled { + params["disabled"] = true + } + if clipConfigEnabled { + params["disabled"] = false + } + + if len(params) == 0 { + fmt.Println("No config options specified") + return + } + + req := models.Request{ + ID: 1, + Method: "clipboard.setConfig", + Params: params, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to set config: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Println("Config updated") +} + +func runClipExport(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.getHistory", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard history: %v", err) + } + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + if resp.Result == nil { + log.Fatal("No clipboard history") + } + + out, err := json.MarshalIndent(resp.Result, "", " ") + if err != nil { + log.Fatalf("Failed to marshal: %v", err) + } + + if len(args) == 0 { + fmt.Println(string(out)) + return + } + + if err := os.WriteFile(args[0], out, 0o644); err != nil { + log.Fatalf("Failed to write file: %v", err) + } + fmt.Printf("Exported to %s\n", args[0]) +} + +func runClipImport(cmd *cobra.Command, args []string) { + data, err := os.ReadFile(args[0]) + if err != nil { + log.Fatalf("Failed to read file: %v", err) + } + + var entries []map[string]any + if err := json.Unmarshal(data, &entries); err != nil { + log.Fatalf("Failed to parse JSON: %v", err) + } + + var imported int + for _, entry := range entries { + dataStr, ok := entry["data"].(string) + if !ok { + continue + } + mimeType, _ := entry["mimeType"].(string) + if mimeType == "" { + mimeType = "text/plain" + } + + var entryData []byte + if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil { + entryData = decoded + } else { + entryData = []byte(dataStr) + } + + if err := clipboard.Store(entryData, mimeType); err != nil { + log.Errorf("Failed to store entry: %v", err) + continue + } + imported++ + } + + fmt.Printf("Imported %d entries\n", imported) +} + +func runClipMigrate(cmd *cobra.Command, args []string) { + dbPath := getCliphistPath() + if len(args) > 0 { + dbPath = args[0] + } + + if _, err := os.Stat(dbPath); err != nil { + log.Fatalf("Cliphist db not found: %s", dbPath) + } + + db, err := bolt.Open(dbPath, 0o644, &bolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + if err != nil { + log.Fatalf("Failed to open cliphist db: %v", err) + } + defer db.Close() + + var migrated int + err = db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("b")) + if b == nil { + return fmt.Errorf("cliphist bucket not found") + } + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + if len(v) == 0 { + continue + } + + mimeType := detectMimeType(v) + if err := clipboard.Store(v, mimeType); err != nil { + log.Errorf("Failed to store entry %d: %v", btoi(k), err) + continue + } + migrated++ + } + return nil + }) + if err != nil { + log.Fatalf("Migration failed: %v", err) + } + + fmt.Printf("Migrated %d entries from cliphist\n", migrated) + + if !clipMigrateDelete { + return + } + + db.Close() + if err := os.Remove(dbPath); err != nil { + log.Errorf("Failed to delete cliphist db: %v", err) + return + } + os.Remove(filepath.Dir(dbPath)) + fmt.Println("Deleted cliphist db") +} + +func getCliphistPath() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db") + } + return filepath.Join(cacheDir, "cliphist", "db") +} + +func detectMimeType(data []byte) string { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + return "image/png" + } + return "text/plain" +} + +func btoi(v []byte) uint64 { + return binary.BigEndian.Uint64(v) +} + +func downloadToTempFile(rawURL string) (string, error) { + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + return "", fmt.Errorf("invalid URL: %s", rawURL) + } + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + + ext := filepath.Ext(parsedURL.Path) + if ext == "" { + ext = ".png" + } + + client := &http.Client{Timeout: 30 * time.Second} + + var data []byte + var contentType string + var lastErr error + + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) + } + + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + lastErr = fmt.Errorf("create request: %w", err) + continue + } + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "image/*,video/*,*/*") + + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("download (attempt %d): %w", attempt+1, err) + continue + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + lastErr = fmt.Errorf("download failed (attempt %d): status %d", attempt+1, resp.StatusCode) + continue + } + + data, err = io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = fmt.Errorf("read response (attempt %d): %w", attempt+1, err) + continue + } + + contentType = resp.Header.Get("Content-Type") + if idx := strings.Index(contentType, ";"); idx != -1 { + contentType = strings.TrimSpace(contentType[:idx]) + } + + lastErr = nil + break + } + + if lastErr != nil { + return "", lastErr + } + + if len(data) == 0 { + return "", fmt.Errorf("downloaded empty file") + } + + if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil { + return "", fmt.Errorf("not a valid media file (content-type: %s)", contentType) + } + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = "/tmp" + } + clipDir := filepath.Join(cacheDir, "dms", "clipboard") + if err := os.MkdirAll(clipDir, 0o755); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)) + if err := os.WriteFile(filePath, data, 0o644); err != nil { + return "", fmt.Errorf("write file: %w", err) + } + + return filePath, nil +} + +func copyFileToClipboard(filePath string) error { + req := models.Request{ + ID: 1, + Method: "clipboard.copyFile", + Params: map[string]any{"filePath": filePath}, + } + + resp, err := sendServerRequest(req) + if err != nil { + return fmt.Errorf("server request: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("server error: %s", resp.Error) + } + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_colorpicker.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_colorpicker.go new file mode 100644 index 0000000..7c366e8 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_colorpicker.go @@ -0,0 +1,139 @@ +package main + +import ( + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker" + "github.com/spf13/cobra" +) + +var ( + colorOutputFmt string + colorAutocopy bool + colorNotify bool + colorLowercase bool +) + +var colorCmd = &cobra.Command{ + Use: "color", + Short: "Color utilities", + Long: "Color utilities including picking colors from the screen", +} + +var colorPickCmd = &cobra.Command{ + Use: "pick", + Short: "Pick a color from the screen", + Long: `Pick a color from anywhere on your screen using an interactive color picker. + +Click on any pixel to capture its color, or press Escape to cancel. + +Output format flags (mutually exclusive, default: --hex): + --hex - Hexadecimal (#RRGGBB) + --rgb - RGB values (R G B) + --hsl - HSL values (H S% L%) + --hsv - HSV values (H S% V%) + --cmyk - CMYK values (C% M% Y% K%) + --json - JSON with all formats + +Optional: + --raw - Removes ANSI escape codes and background colors. Use this when piping to other commands + +Examples: + dms color pick # Pick color, output as hex + dms color pick --rgb # Output as RGB + dms color pick --json # Output all formats as JSON + dms color pick --hex -l # Output hex in lowercase + dms color pick -a # Auto-copy result to clipboard`, + Run: runColorPick, +} + +func init() { + colorPickCmd.Flags().Bool("hex", false, "Output as hexadecimal (#RRGGBB)") + colorPickCmd.Flags().Bool("rgb", false, "Output as RGB (R G B)") + colorPickCmd.Flags().Bool("hsl", false, "Output as HSL (H S% L%)") + colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)") + colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)") + colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON") + colorPickCmd.Flags().Bool("raw", false, "Removes ANSI escape codes and background colors. Use this when piping to other commands") + colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template") + colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard") + colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase") + + colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json") + + colorCmd.AddCommand(colorPickCmd) +} + +func runColorPick(cmd *cobra.Command, args []string) { + format := colorpicker.FormatHex // default + jsonOutput, _ := cmd.Flags().GetBool("json") + + if rgb, _ := cmd.Flags().GetBool("rgb"); rgb { + format = colorpicker.FormatRGB + } else if hsl, _ := cmd.Flags().GetBool("hsl"); hsl { + format = colorpicker.FormatHSL + } else if hsv, _ := cmd.Flags().GetBool("hsv"); hsv { + format = colorpicker.FormatHSV + } else if cmyk, _ := cmd.Flags().GetBool("cmyk"); cmyk { + format = colorpicker.FormatCMYK + } + + config := colorpicker.Config{ + Format: format, + CustomFormat: colorOutputFmt, + Lowercase: colorLowercase, + Autocopy: colorAutocopy, + Notify: colorNotify, + } + + picker := colorpicker.New(config) + color, err := picker.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if color == nil { + os.Exit(0) + } + + var output string + if jsonOutput { + jsonStr, err := color.ToJSON() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + output = jsonStr + } else { + output = color.Format(config.Format, config.Lowercase, config.CustomFormat) + } + + if colorAutocopy { + copyToClipboard(output) + } + + if jsonOutput { + fmt.Println(output) + return + } + + if raw, _ := cmd.Flags().GetBool("raw"); raw { + fmt.Printf("%s\n", output) + return + } + + if color.IsDark() { + fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output) + } else { + fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output) + } +} + +func copyToClipboard(text string) { + if err := clipboard.CopyText(text); err != nil { + fmt.Fprintln(os.Stderr, "clipboard copy failed:", err) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_common.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_common.go new file mode 100644 index 0000000..6557860 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_common.go @@ -0,0 +1,530 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version information", + Run: runVersion, +} + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Launch quickshell with DMS configuration", + Long: "Launch quickshell with DMS configuration (qs -c dms)", + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + daemon, _ := cmd.Flags().GetBool("daemon") + session, _ := cmd.Flags().GetBool("session") + if daemon { + runShellDaemon(session) + } else { + runShellInteractive(session) + } + }, +} + +var restartCmd = &cobra.Command{ + Use: "restart", + Short: "Restart quickshell with DMS configuration", + Long: "Kill existing DMS shell processes and restart quickshell with DMS configuration", + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + restartShell() + }, +} + +var restartDetachedCmd = &cobra.Command{ + Use: "restart-detached <pid>", + Hidden: true, + Args: cobra.ExactArgs(1), + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + runDetachedRestart(args[0]) + }, +} + +var killCmd = &cobra.Command{ + Use: "kill", + Short: "Kill running DMS shell processes", + Long: "Kill all running quickshell processes with DMS configuration", + Run: func(cmd *cobra.Command, args []string) { + killShell() + }, +} + +var ipcCmd = &cobra.Command{ + Use: "ipc [target] [function] [args...]", + Short: "Send IPC commands to running DMS shell", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = findConfig(cmd, args) + return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + runShellIPCCommand(args) + }, +} + +func init() { + ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + _ = findConfig(cmd, args) + printIPCHelp() + }) +} + +var debugSrvCmd = &cobra.Command{ + Use: "debug-srv", + Short: "Start the debug server", + Long: "Start the Unix socket debug server for DMS", + Run: func(cmd *cobra.Command, args []string) { + if err := startDebugServer(); err != nil { + log.Fatalf("Error starting debug server: %v", err) + } + }, +} + +var pluginsCmd = &cobra.Command{ + Use: "plugins", + Short: "Manage DMS plugins", + Long: "Browse and manage DMS plugins from the registry", +} + +var pluginsBrowseCmd = &cobra.Command{ + Use: "browse", + Short: "Browse available plugins", + Long: "Browse available plugins from the DMS plugin registry", + Run: func(cmd *cobra.Command, args []string) { + if err := browsePlugins(); err != nil { + log.Fatalf("Error browsing plugins: %v", err) + } + }, +} + +var pluginsListCmd = &cobra.Command{ + Use: "list", + Short: "List installed plugins", + Long: "List all installed DMS plugins", + Run: func(cmd *cobra.Command, args []string) { + if err := listInstalledPlugins(); err != nil { + log.Fatalf("Error listing plugins: %v", err) + } + }, +} + +var pluginsInstallCmd = &cobra.Command{ + Use: "install <plugin-id>", + Short: "Install a plugin by ID", + Long: "Install a DMS plugin from the registry using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getAvailablePluginIDs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + if err := installPluginCLI(args[0]); err != nil { + log.Fatalf("Error installing plugin: %v", err) + } + }, +} + +var pluginsUninstallCmd = &cobra.Command{ + Use: "uninstall <plugin-id>", + Short: "Uninstall a plugin by ID", + Long: "Uninstall a DMS plugin using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + if err := uninstallPluginCLI(args[0]); err != nil { + log.Fatalf("Error uninstalling plugin: %v", err) + } + }, +} + +var pluginsUpdateCmd = &cobra.Command{ + Use: "update <plugin-id>", + Short: "Update a plugin by ID", + Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + if err := updatePluginCLI(args[0]); err != nil { + log.Fatalf("Error updating plugin: %v", err) + } + }, +} + +func runVersion(cmd *cobra.Command, args []string) { + fmt.Printf("%s\n", formatVersion(Version)) +} + +// Git builds: dms (git) v0.6.2-XXXX +// Stable releases: dms v0.6.2 +func formatVersion(version string) string { + // Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84 + re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`) + if matches := re.FindStringSubmatch(version); matches != nil { + return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2]) + } + + // Fedora COPR git format: 0.0.git.2267.d430cae9 + re = regexp.MustCompile(`^[\d.]+\.git\.(\d+)\.`) + if matches := re.FindStringSubmatch(version); matches != nil { + baseVersion := getBaseVersion() + return fmt.Sprintf("dms (git) v%s-%s", baseVersion, matches[1]) + } + + // Stable release format: 0.6.2 + re = regexp.MustCompile(`^([\d.]+)$`) + if matches := re.FindStringSubmatch(version); matches != nil { + return fmt.Sprintf("dms v%s", matches[1]) + } + + return fmt.Sprintf("dms %s", version) +} + +func getBaseVersion() string { + paths := []string{ + "/usr/share/quickshell/dms/VERSION", + "/usr/local/share/quickshell/dms/VERSION", + "/etc/xdg/quickshell/dms/VERSION", + } + + for _, path := range paths { + if content, err := os.ReadFile(path); err == nil { + ver := strings.TrimSpace(string(content)) + ver = strings.TrimPrefix(ver, "v") + if re := regexp.MustCompile(`^([\d.]+)`); re.MatchString(ver) { + if matches := re.FindStringSubmatch(ver); matches != nil { + return matches[1] + } + } + } + } + + // Fallback + return "1.0.2" +} + +func startDebugServer() error { + server.CLIVersion = Version + return server.Start(true) +} + +func browsePlugins() error { + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + fmt.Println("Fetching plugin registry...") + pluginList, err := registry.List() + if err != nil { + return fmt.Errorf("failed to list plugins: %w", err) + } + + if len(pluginList) == 0 { + fmt.Println("No plugins found in registry.") + return nil + } + + fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList)) + for _, plugin := range pluginList { + installed, _ := manager.IsInstalled(plugin) + installedMarker := "" + if installed { + installedMarker = " [Installed]" + } + + fmt.Printf(" %s%s\n", plugin.Name, installedMarker) + fmt.Printf(" ID: %s\n", plugin.ID) + fmt.Printf(" Category: %s\n", plugin.Category) + fmt.Printf(" Author: %s\n", plugin.Author) + fmt.Printf(" Description: %s\n", plugin.Description) + fmt.Printf(" Repository: %s\n", plugin.Repo) + if len(plugin.Capabilities) > 0 { + fmt.Printf(" Capabilities: %s\n", strings.Join(plugin.Capabilities, ", ")) + } + if len(plugin.Compositors) > 0 { + fmt.Printf(" Compositors: %s\n", strings.Join(plugin.Compositors, ", ")) + } + if len(plugin.Dependencies) > 0 { + fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", ")) + } + fmt.Println() + } + + return nil +} + +func listInstalledPlugins() error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + installedNames, err := manager.ListInstalled() + if err != nil { + return fmt.Errorf("failed to list installed plugins: %w", err) + } + + if len(installedNames) == 0 { + fmt.Println("No plugins installed.") + return nil + } + + allPlugins, err := registry.List() + if err != nil { + return fmt.Errorf("failed to list plugins: %w", err) + } + + pluginMap := make(map[string]plugins.Plugin) + for _, p := range allPlugins { + pluginMap[p.ID] = p + } + + fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames)) + for _, id := range installedNames { + if plugin, ok := pluginMap[id]; ok { + fmt.Printf(" %s\n", plugin.Name) + fmt.Printf(" ID: %s\n", plugin.ID) + fmt.Printf(" Category: %s\n", plugin.Category) + fmt.Printf(" Author: %s\n", plugin.Author) + fmt.Println() + } else { + fmt.Printf(" %s (not in registry)\n\n", id) + } + } + + return nil +} + +func installPluginCLI(idOrName string) error { + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + pluginList, err := registry.List() + if err != nil { + return fmt.Errorf("failed to list plugins: %w", err) + } + + // First, try to find by ID (preferred method) + var plugin *plugins.Plugin + for _, p := range pluginList { + if p.ID == idOrName { + plugin = &p + break + } + } + + // Fallback to name for backward compatibility + if plugin == nil { + for _, p := range pluginList { + if p.Name == idOrName { + plugin = &p + break + } + } + } + + if plugin == nil { + return fmt.Errorf("plugin not found: %s", idOrName) + } + + installed, err := manager.IsInstalled(*plugin) + if err != nil { + return fmt.Errorf("failed to check install status: %w", err) + } + + if installed { + return fmt.Errorf("plugin already installed: %s", plugin.Name) + } + + fmt.Printf("Installing plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + if err := manager.Install(*plugin); err != nil { + return fmt.Errorf("failed to install plugin: %w", err) + } + + fmt.Printf("Plugin installed successfully: %s\n", plugin.Name) + return nil +} + +func getAvailablePluginIDs() []string { + registry, err := plugins.NewRegistry() + if err != nil { + return nil + } + + pluginList, err := registry.List() + if err != nil { + return nil + } + + var ids []string + for _, p := range pluginList { + ids = append(ids, p.ID) + } + return ids +} + +func getInstalledPluginIDs() []string { + manager, err := plugins.NewManager() + if err != nil { + return nil + } + + installed, err := manager.ListInstalled() + if err != nil { + return nil + } + + return installed +} + +func uninstallPluginCLI(idOrName string) error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + pluginList, _ := registry.List() + plugin := plugins.FindByIDOrName(idOrName, pluginList) + + if plugin != nil { + installed, err := manager.IsInstalled(*plugin) + if err != nil { + return fmt.Errorf("failed to check install status: %w", err) + } + if !installed { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + fmt.Printf("Uninstalling plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + if err := manager.Uninstall(*plugin); err != nil { + return fmt.Errorf("failed to uninstall plugin: %w", err) + } + fmt.Printf("Plugin uninstalled successfully: %s\n", plugin.Name) + return nil + } + + fmt.Printf("Uninstalling plugin: %s\n", idOrName) + if err := manager.UninstallByIDOrName(idOrName); err != nil { + return err + } + fmt.Printf("Plugin uninstalled successfully: %s\n", idOrName) + return nil +} + +func updatePluginCLI(idOrName string) error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + pluginList, _ := registry.List() + plugin := plugins.FindByIDOrName(idOrName, pluginList) + + if plugin != nil { + installed, err := manager.IsInstalled(*plugin) + if err != nil { + return fmt.Errorf("failed to check install status: %w", err) + } + if !installed { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + if err := manager.Update(*plugin); err != nil { + return fmt.Errorf("failed to update plugin: %w", err) + } + fmt.Printf("Plugin updated successfully: %s\n", plugin.Name) + return nil + } + + fmt.Printf("Updating plugin: %s\n", idOrName) + if err := manager.UpdateByIDOrName(idOrName); err != nil { + return err + } + fmt.Printf("Plugin updated successfully: %s\n", idOrName) + return nil +} + +func getCommonCommands() []*cobra.Command { + return []*cobra.Command{ + versionCmd, + runCmd, + restartCmd, + restartDetachedCmd, + killCmd, + ipcCmd, + debugSrvCmd, + pluginsCmd, + dank16Cmd, + brightnessCmd, + dpmsCmd, + keybindsCmd, + greeterCmd, + setupCmd, + colorCmd, + screenshotCmd, + notifyActionCmd, + notifyCmd, + genericNotifyActionCmd, + matugenCmd, + clipboardCmd, + chromaCmd, + doctorCmd, + configCmd, + dlCmd, + randrCmd, + blurCmd, + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_config.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_config.go new file mode 100644 index 0000000..d604256 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_config.go @@ -0,0 +1,318 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Configuration utilities", +} + +var resolveIncludeCmd = &cobra.Command{ + Use: "resolve-include <compositor> <filename>", + Short: "Check if a file is included in compositor config", + Long: "Recursively check if a file is included/sourced in compositor configuration. Returns JSON with exists and included status.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp + case 1: + return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runResolveInclude, +} + +func init() { + configCmd.AddCommand(resolveIncludeCmd) +} + +type IncludeResult struct { + Exists bool `json:"exists"` + Included bool `json:"included"` +} + +func runResolveInclude(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + filename := args[1] + + var result IncludeResult + var err error + + switch compositor { + case "hyprland": + result, err = checkHyprlandInclude(filename) + case "niri": + result, err = checkNiriInclude(filename) + case "mangowc", "dwl", "mango": + result, err = checkMangoWCInclude(filename) + default: + log.Fatalf("Unknown compositor: %s", compositor) + } + + if err != nil { + log.Fatalf("Error checking include: %v", err) + } + + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) +} + +func checkHyprlandInclude(filename string) (IncludeResult, error) { + configDir, err := utils.ExpandPath("$HOME/.config/hypr") + if err != nil { + return IncludeResult{}, err + } + + targetPath := filepath.Join(configDir, "dms", filename) + result := IncludeResult{} + + if _, err := os.Stat(targetPath); err == nil { + result.Exists = true + } + + mainConfig := filepath.Join(configDir, "hyprland.conf") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + return result, nil + } + + processed := make(map[string]bool) + result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed) + return result, nil +} + +func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool { + absPath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + if processed[absPath] { + return false + } + processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + + baseDir := filepath.Dir(absPath) + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue + } + + if !strings.HasPrefix(trimmed, "source") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) < 2 { + continue + } + + sourcePath := strings.TrimSpace(parts[1]) + if matchesTarget(sourcePath, target) { + return true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + continue + } + + if hyprlandFindInclude(expanded, target, processed) { + return true + } + } + + return false +} + +func checkNiriInclude(filename string) (IncludeResult, error) { + configDir, err := utils.ExpandPath("$HOME/.config/niri") + if err != nil { + return IncludeResult{}, err + } + + targetPath := filepath.Join(configDir, "dms", filename) + result := IncludeResult{} + + if _, err := os.Stat(targetPath); err == nil { + result.Exists = true + } + + mainConfig := filepath.Join(configDir, "config.kdl") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + return result, nil + } + + processed := make(map[string]bool) + result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed) + return result, nil +} + +func niriFindInclude(filePath, target string, processed map[string]bool) bool { + absPath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + if processed[absPath] { + return false + } + processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + + baseDir := filepath.Dir(absPath) + content := string(data) + + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") || trimmed == "" { + continue + } + + if !strings.HasPrefix(trimmed, "include") { + continue + } + + startQuote := strings.Index(trimmed, "\"") + if startQuote == -1 { + continue + } + endQuote := strings.LastIndex(trimmed, "\"") + if endQuote <= startQuote { + continue + } + + includePath := trimmed[startQuote+1 : endQuote] + if matchesTarget(includePath, target) { + return true + } + + fullPath := includePath + if !filepath.IsAbs(includePath) { + fullPath = filepath.Join(baseDir, includePath) + } + + if niriFindInclude(fullPath, target, processed) { + return true + } + } + + return false +} + +func checkMangoWCInclude(filename string) (IncludeResult, error) { + configDir, err := utils.ExpandPath("$HOME/.config/mango") + if err != nil { + return IncludeResult{}, err + } + + targetPath := filepath.Join(configDir, "dms", filename) + result := IncludeResult{} + + if _, err := os.Stat(targetPath); err == nil { + result.Exists = true + } + + mainConfig := filepath.Join(configDir, "config.conf") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + mainConfig = filepath.Join(configDir, "mango.conf") + } + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + return result, nil + } + + processed := make(map[string]bool) + result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed) + return result, nil +} + +func mangowcFindInclude(filePath, target string, processed map[string]bool) bool { + absPath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + if processed[absPath] { + return false + } + processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + + baseDir := filepath.Dir(absPath) + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue + } + + if !strings.HasPrefix(trimmed, "source") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) < 2 { + continue + } + + sourcePath := strings.TrimSpace(parts[1]) + if matchesTarget(sourcePath, target) { + return true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + continue + } + + if mangowcFindInclude(expanded, target, processed) { + return true + } + } + + return false +} + +func matchesTarget(path, target string) bool { + path = strings.TrimPrefix(path, "./") + target = strings.TrimPrefix(target, "./") + return path == target || strings.HasSuffix(path, "/"+target) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_dank16.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_dank16.go new file mode 100644 index 0000000..0bbbab1 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_dank16.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/dank16" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var dank16Cmd = &cobra.Command{ + Use: "dank16 [hex_color]", + Short: "Generate Base16 color palettes", + Long: "Generate Base16 color palettes from a color with support for various output formats", + Args: cobra.MaximumNArgs(1), + Run: runDank16, +} + +func init() { + dank16Cmd.Flags().Bool("light", false, "Generate light theme variant (sets default to light)") + dank16Cmd.Flags().Bool("json", false, "Output in JSON format") + dank16Cmd.Flags().Bool("kitty", false, "Output in Kitty terminal format") + dank16Cmd.Flags().Bool("foot", false, "Output in Foot terminal format") + dank16Cmd.Flags().Bool("neovim", false, "Output in Neovim plugin format") + dank16Cmd.Flags().Bool("alacritty", false, "Output in Alacritty terminal format") + dank16Cmd.Flags().Bool("ghostty", false, "Output in Ghostty terminal format") + dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format") + dank16Cmd.Flags().String("background", "", "Custom background color") + dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag") + dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON") + dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)") + dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)") + _ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp + }) +} + +func runDank16(cmd *cobra.Command, args []string) { + isLight, _ := cmd.Flags().GetBool("light") + isJson, _ := cmd.Flags().GetBool("json") + isKitty, _ := cmd.Flags().GetBool("kitty") + isFoot, _ := cmd.Flags().GetBool("foot") + isNeovim, _ := cmd.Flags().GetBool("neovim") + isAlacritty, _ := cmd.Flags().GetBool("alacritty") + isGhostty, _ := cmd.Flags().GetBool("ghostty") + isWezterm, _ := cmd.Flags().GetBool("wezterm") + background, _ := cmd.Flags().GetString("background") + contrastAlgo, _ := cmd.Flags().GetString("contrast") + useVariants, _ := cmd.Flags().GetBool("variants") + primaryDark, _ := cmd.Flags().GetString("primary-dark") + primaryLight, _ := cmd.Flags().GetString("primary-light") + + if background != "" && !strings.HasPrefix(background, "#") { + background = "#" + background + } + if primaryDark != "" && !strings.HasPrefix(primaryDark, "#") { + primaryDark = "#" + primaryDark + } + if primaryLight != "" && !strings.HasPrefix(primaryLight, "#") { + primaryLight = "#" + primaryLight + } + + contrastAlgo = strings.ToLower(contrastAlgo) + if contrastAlgo != "dps" && contrastAlgo != "wcag" { + log.Fatalf("Invalid contrast algorithm: %s (must be 'dps' or 'wcag')", contrastAlgo) + } + + if useVariants { + if primaryDark == "" || primaryLight == "" { + if len(args) == 0 { + log.Fatalf("--variants requires either a positional color argument or both --primary-dark and --primary-light") + } + primaryColor := args[0] + if !strings.HasPrefix(primaryColor, "#") { + primaryColor = "#" + primaryColor + } + primaryDark = primaryColor + primaryLight = primaryColor + } + variantOpts := dank16.VariantOptions{ + PrimaryDark: primaryDark, + PrimaryLight: primaryLight, + Background: background, + UseDPS: contrastAlgo == "dps", + IsLightMode: isLight, + } + variantColors := dank16.GenerateVariantPalette(variantOpts) + fmt.Print(dank16.GenerateVariantJSON(variantColors)) + return + } + + if len(args) == 0 { + log.Fatalf("A color argument is required (or use --variants with --primary-dark and --primary-light)") + } + primaryColor := args[0] + if !strings.HasPrefix(primaryColor, "#") { + primaryColor = "#" + primaryColor + } + + opts := dank16.PaletteOptions{ + IsLight: isLight, + Background: background, + UseDPS: contrastAlgo == "dps", + } + + colors := dank16.GeneratePalette(primaryColor, opts) + + if isJson { + fmt.Print(dank16.GenerateJSON(colors)) + } else if isKitty { + fmt.Print(dank16.GenerateKittyTheme(colors)) + } else if isFoot { + fmt.Print(dank16.GenerateFootTheme(colors)) + } else if isAlacritty { + fmt.Print(dank16.GenerateAlacrittyTheme(colors)) + } else if isGhostty { + fmt.Print(dank16.GenerateGhosttyTheme(colors)) + } else if isWezterm { + fmt.Print(dank16.GenerateWeztermTheme(colors)) + } else if isNeovim { + fmt.Print(dank16.GenerateNeovimTheme(colors)) + } else { + fmt.Print(dank16.GenerateGhosttyTheme(colors)) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_doctor.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_doctor.go new file mode 100644 index 0000000..b182d06 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_doctor.go @@ -0,0 +1,1107 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "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(`(?i)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 + doctorCopy 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") + doctorCmd.Flags().BoolVarP(&doctorCopy, "copy", "C", false, "Copy results to clipboard in GitHub-friendly 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 && !doctorCopy { + printDoctorHeader() + } + + qsFeatures, qsMissingFeatures := checkQuickshellFeatures() + + results := slices.Concat( + checkSystemInfo(), + checkVersions(qsMissingFeatures), + checkDMSInstallation(), + checkWindowManagers(), + qsFeatures, + checkOptionalDependencies(), + checkConfigurationFiles(), + checkSystemdServices(), + checkEnvironmentVars(), + ) + + switch { + case doctorCopy: + text := formatResultsPlain(results) + if err := clipboard.CopyOpts([]byte(text), "text/plain;charset=utf-8", false, false); err != nil { + fmt.Fprintf(os.Stderr, "Failed to copy to clipboard: %v\n", err) + os.Exit(1) + } + fmt.Println("Doctor report copied to clipboard") + case doctorJSON: + printResultsJSON(results) + default: + 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), 0o644); 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 checkImageFormatPlugins() []checkResult { + url := doctorDocsURL + "#optional-features" + + pluginDirs := findQtPluginDirs() + if len(pluginDirs) == 0 { + return []checkResult{ + {catOptionalFeatures, "qt6-imageformats", statusInfo, "Cannot detect (plugin dir not found)", "WebP, TIFF, JP2 support", url}, + {catOptionalFeatures, "kimageformats", statusInfo, "Cannot detect (plugin dir not found)", "AVIF, HEIF, JXL support", url}, + } + } + + type pluginCheck struct { + name string + desc string + plugins []struct{ file, format string } + } + + checks := []pluginCheck{ + { + name: "qt6-imageformats", + desc: "WebP, TIFF, GIF, JP2 support", + plugins: []struct{ file, format string }{ + {"libqwebp.so", "WebP"}, + {"libqtiff.so", "TIFF"}, + {"libqgif.so", "GIF"}, + {"libqjp2.so", "JP2"}, + {"libqicns.so", "ICNS"}, + }, + }, + { + name: "kimageformats", + desc: "AVIF, HEIF, JXL support", + plugins: []struct{ file, format string }{ + {"kimg_avif.so", "AVIF"}, + {"kimg_heif.so", "HEIF"}, + {"kimg_jxl.so", "JXL"}, + {"kimg_exr.so", "EXR"}, + }, + }, + } + + var results []checkResult + for _, c := range checks { + var found []string + var foundDirs []string + for _, pluginDir := range pluginDirs { + imageFormatsDir := filepath.Join(pluginDir, "imageformats") + for _, p := range c.plugins { + if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil { + if !slices.Contains(found, p.format) { + found = append(found, p.format) + } + if !slices.Contains(foundDirs, imageFormatsDir) { + foundDirs = append(foundDirs, imageFormatsDir) + } + } + } + } + + var result checkResult + switch { + case len(found) == 0: + result = checkResult{catOptionalFeatures, c.name, statusWarn, "Not installed", c.desc, url} + default: + details := "" + if doctorVerbose { + details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), strings.Join(foundDirs, ":")) + } + result = checkResult{catOptionalFeatures, c.name, statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url} + } + results = append(results, result) + } + + return results +} + +func findQtPluginDirs() []string { + var dirs []string + + addDir := func(dir string) { + if dir != "" { + if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil { + dirs = append(dirs, dir) + } + } + } + + // Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups) + if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" { + for dir := range strings.SplitSeq(envPath, ":") { + addDir(dir) + } + } + + // Try qtpaths + for _, cmd := range []string{"qtpaths6", "qtpaths"} { + if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil { + addDir(strings.TrimSpace(string(output))) + } + } + + // Fallback: common distro paths + for _, dir := range []string{ + "/usr/lib/qt6/plugins", + "/usr/lib64/qt6/plugins", + "/usr/lib/x86_64-linux-gnu/qt6/plugins", + "/usr/lib/aarch64-linux-gnu/qt6/plugins", + } { + addDir(dir) + } + + return dirs +} + +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}) + + cupsPkHelperBus := "org.opensuse.CupsPkHelper.Mechanism" + var cupsPkStatus status + var cupsPkMsg string + switch { + case utils.IsDBusServiceAvailable(cupsPkHelperBus): + cupsPkStatus, cupsPkMsg = statusOK, "Running" + case utils.IsDBusServiceActivatable(cupsPkHelperBus): + cupsPkStatus, cupsPkMsg = statusOK, "Available" + default: + cupsPkStatus, cupsPkMsg = statusWarn, "Not available (install cups-pk-helper)" + } + results = append(results, checkResult{catOptionalFeatures, "cups-pk-helper", cupsPkStatus, cupsPkMsg, "Printer management", optionalFeaturesURL}) + + results = append(results, checkI2CAvailability()) + results = append(results, checkImageFormatPlugins()...) + + terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"} + terminals = slices.DeleteFunc(terminals, func(t string) bool { + return !utils.CommandExists(t) + }) + + if len(terminals) > 0 { + results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL}) + } else { + results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot 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()&0o200 == 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() +} + +func formatResultsPlain(results []checkResult) string { + var sb strings.Builder + sb.WriteString("## DMS Doctor Report\n\n") + + currentCategory := category(-1) + for _, r := range results { + if r.category != currentCategory { + if currentCategory != -1 { + sb.WriteString("\n") + } + fmt.Fprintf(&sb, "**%s**\n", r.category.String()) + currentCategory = r.category + } + + fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message) + + if doctorVerbose && r.details != "" { + fmt.Fprintf(&sb, " - %s\n", r.details) + } + } + + var ds DoctorStatus + for _, r := range results { + ds.Add(r) + } + + sb.WriteString("\n---\n") + fmt.Fprintf(&sb, "**Summary:** %d error(s), %d warning(s), %d ok\n", + ds.ErrorCount(), ds.WarningCount(), ds.OKCount()) + + return sb.String() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_download.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_download.go new file mode 100644 index 0000000..e9caeb0 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_download.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" +) + +var dlOutput string +var dlUserAgent string +var dlTimeout int +var dlIPv4Only bool + +var dlCmd = &cobra.Command{ + Use: "dl <url>", + Short: "Download a URL to stdout or file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runDownload(args[0]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, +} + +func init() { + dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)") + dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header") + dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds") + dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only") +} + +func runDownload(url string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + switch { + case dlUserAgent != "": + req.Header.Set("User-Agent", dlUserAgent) + default: + req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)") + } + + dialer := &net.Dialer{Timeout: 5 * time.Second} + transport := &http.Transport{DialContext: dialer.DialContext} + if dlIPv4Only { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp4", addr) + } + } + client := &http.Client{Transport: transport} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + if dlOutput == "" { + _, err = io.Copy(os.Stdout, resp.Body) + return err + } + + if dir := filepath.Dir(dlOutput); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir failed: %w", err) + } + } + + f, err := os.Create(dlOutput) + if err != nil { + return fmt.Errorf("create failed: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + os.Remove(dlOutput) + return fmt.Errorf("write failed: %w", err) + } + + fmt.Println(dlOutput) + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_dpms.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_dpms.go new file mode 100644 index 0000000..77eff1a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_dpms.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var dpmsCmd = &cobra.Command{ + Use: "dpms", + Short: "Control display power management", +} + +var dpmsOnCmd = &cobra.Command{ + Use: "on [output]", + Short: "Turn display(s) on", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getDPMSOutputs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: runDPMSOn, +} + +var dpmsOffCmd = &cobra.Command{ + Use: "off [output]", + Short: "Turn display(s) off", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getDPMSOutputs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: runDPMSOff, +} + +var dpmsListCmd = &cobra.Command{ + Use: "list", + Short: "List outputs", + Args: cobra.NoArgs, + Run: runDPMSList, +} + +func init() { + dpmsCmd.AddCommand(dpmsOnCmd, dpmsOffCmd, dpmsListCmd) +} + +func runDPMSOn(cmd *cobra.Command, args []string) { + outputName := "" + if len(args) > 0 { + outputName = args[0] + } + + client, err := newDPMSClient() + if err != nil { + log.Fatalf("%v", err) + } + defer client.Close() + + if err := client.SetDPMS(outputName, true); err != nil { + log.Fatalf("%v", err) + } +} + +func runDPMSOff(cmd *cobra.Command, args []string) { + outputName := "" + if len(args) > 0 { + outputName = args[0] + } + + client, err := newDPMSClient() + if err != nil { + log.Fatalf("%v", err) + } + defer client.Close() + + if err := client.SetDPMS(outputName, false); err != nil { + log.Fatalf("%v", err) + } +} + +func getDPMSOutputs() []string { + client, err := newDPMSClient() + if err != nil { + return nil + } + defer client.Close() + return client.ListOutputs() +} + +func runDPMSList(cmd *cobra.Command, args []string) { + client, err := newDPMSClient() + if err != nil { + log.Fatalf("%v", err) + } + defer client.Close() + + for _, output := range client.ListOutputs() { + fmt.Println(output) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_features.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_features.go new file mode 100644 index 0000000..7ec5437 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_features.go @@ -0,0 +1,491 @@ +//go:build !distro_binary + +package main + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/version" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update DankMaterialShell to the latest version", + Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution", + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + runUpdate() + }, +} + +var updateCheckCmd = &cobra.Command{ + Use: "check", + Short: "Check if updates are available for DankMaterialShell", + Long: "Check for available updates without performing the actual update", + Run: func(cmd *cobra.Command, args []string) { + runUpdateCheck() + }, +} + +func runUpdateCheck() { + fmt.Println("Checking for DankMaterialShell updates...") + fmt.Println() + + versionInfo, err := version.GetDMSVersionInfo() + if err != nil { + log.Fatalf("Error checking for updates: %v", err) + } + + fmt.Printf("Current version: %s\n", versionInfo.Current) + fmt.Printf("Latest version: %s\n", versionInfo.Latest) + fmt.Println() + + if versionInfo.HasUpdate { + fmt.Println("✓ Update available!") + fmt.Println() + fmt.Println("Run 'dms update' to install the latest version.") + os.Exit(0) + } else { + fmt.Println("✓ You are running the latest version.") + os.Exit(0) + } +} + +func runUpdate() { + osInfo, err := distros.GetOSInfo() + if err != nil { + log.Fatalf("Error detecting OS: %v", err) + } + + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID) + } + + var updateErr error + switch config.Family { + case distros.FamilyArch: + updateErr = updateArchLinux() + case distros.FamilySUSE: + updateErr = updateOtherDistros() + default: + updateErr = updateOtherDistros() + } + + if updateErr != nil { + if errors.Is(updateErr, errdefs.ErrUpdateCancelled) { + log.Info("Update cancelled.") + return + } + if errors.Is(updateErr, errdefs.ErrNoUpdateNeeded) { + return + } + log.Fatalf("Error updating DMS: %v", updateErr) + } + + log.Info("Update complete! Restarting DMS...") + restartShell() +} + +func updateArchLinux() error { + homeDir, err := os.UserHomeDir() + if err == nil { + dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") + if _, err := os.Stat(dmsPath); err == nil { + return updateOtherDistros() + } + } + + var packageName string + var isAUR bool + if isArchPackageInstalled("dms-shell") { + packageName = "dms-shell" + } else if isArchPackageInstalled("dms-shell-git") { + packageName = "dms-shell-git" + isAUR = true + } else if isArchPackageInstalled("dms-shell-bin") { + packageName = "dms-shell-bin" + isAUR = true + } else { + fmt.Println("Info: No dms-shell package found.") + fmt.Println("Info: Falling back to git-based update method...") + return updateOtherDistros() + } + + if !isAUR { + fmt.Printf("This will update %s using pacman.\n", packageName) + if !confirmUpdate() { + return errdefs.ErrUpdateCancelled + } + + fmt.Printf("\nRunning: sudo pacman -S %s\n", packageName) + cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", packageName) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf("Error: Failed to update using pacman: %v\n", err) + return err + } + + fmt.Println("dms successfully updated") + return nil + } + + var helper string + var updateCmd *exec.Cmd + + if utils.CommandExists("yay") { + helper = "yay" + updateCmd = exec.Command("yay", "-S", packageName) + } else if utils.CommandExists("paru") { + helper = "paru" + updateCmd = exec.Command("paru", "-S", packageName) + } else { + fmt.Println("Error: Neither yay nor paru found - please install an AUR helper") + fmt.Println("Info: Falling back to git-based update method...") + return updateOtherDistros() + } + + fmt.Printf("This will update DankMaterialShell using %s.\n", helper) + if !confirmUpdate() { + return errdefs.ErrUpdateCancelled + } + + fmt.Printf("\nRunning: %s -S %s\n", helper, packageName) + updateCmd.Stdout = os.Stdout + updateCmd.Stderr = os.Stderr + err = updateCmd.Run() + if err != nil { + fmt.Printf("Error: Failed to update using %s: %v\n", helper, err) + } + + fmt.Println("dms successfully updated") + return nil +} + +func updateOtherDistros() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") + + if _, err := os.Stat(dmsPath); os.IsNotExist(err) { + return fmt.Errorf("DMS configuration directory not found at %s", dmsPath) + } + + fmt.Printf("Found DMS configuration at %s\n", dmsPath) + + versionInfo, err := version.GetDMSVersionInfo() + if err == nil && !versionInfo.HasUpdate { + fmt.Println() + fmt.Printf("Current version: %s\n", versionInfo.Current) + fmt.Printf("Latest version: %s\n", versionInfo.Latest) + fmt.Println() + fmt.Println("✓ You are already running the latest version.") + return errdefs.ErrNoUpdateNeeded + } + + fmt.Println("\nThis will update:") + fmt.Println(" 1. The dms binary from GitHub releases") + fmt.Println(" 2. DankMaterialShell configuration using git") + if !confirmUpdate() { + return errdefs.ErrUpdateCancelled + } + + fmt.Println("\n=== Updating dms binary ===") + if err := updateDMSBinary(); err != nil { + fmt.Printf("Warning: Failed to update dms binary: %v\n", err) + fmt.Println("Continuing with shell configuration update...") + } else { + fmt.Println("dms binary successfully updated") + } + + fmt.Println("\n=== Updating DMS shell configuration ===") + + if err := os.Chdir(dmsPath); err != nil { + return fmt.Errorf("failed to change to DMS directory: %w", err) + } + + statusCmd := exec.Command("git", "status", "--porcelain") + statusOutput, _ := statusCmd.Output() + hasLocalChanges := len(strings.TrimSpace(string(statusOutput))) > 0 + + currentRefCmd := exec.Command("git", "symbolic-ref", "-q", "HEAD") + currentRefOutput, _ := currentRefCmd.Output() + onBranch := len(currentRefOutput) > 0 + + var currentTag string + var currentBranch string + + if !onBranch { + tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD") + if tagOutput, err := tagCmd.Output(); err == nil { + currentTag = strings.TrimSpace(string(tagOutput)) + } + } else { + branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + if branchOutput, err := branchCmd.Output(); err == nil { + currentBranch = strings.TrimSpace(string(branchOutput)) + } + } + + fmt.Println("Fetching latest changes...") + fetchCmd := exec.Command("git", "fetch", "origin", "--tags", "--force") + fetchCmd.Stdout = os.Stdout + fetchCmd.Stderr = os.Stderr + if err := fetchCmd.Run(); err != nil { + return fmt.Errorf("failed to fetch changes: %w", err) + } + + if currentTag != "" { + latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname") + latestTagOutput, err := latestTagCmd.Output() + if err != nil { + return fmt.Errorf("failed to get latest tag: %w", err) + } + + tags := strings.Split(strings.TrimSpace(string(latestTagOutput)), "\n") + if len(tags) == 0 || tags[0] == "" { + return fmt.Errorf("no version tags found") + } + latestTag := tags[0] + + if latestTag == currentTag { + fmt.Printf("Already on latest tag: %s\n", currentTag) + return nil + } + + fmt.Printf("Current tag: %s\n", currentTag) + fmt.Printf("Latest tag: %s\n", latestTag) + + if hasLocalChanges { + fmt.Println("\nWarning: You have local changes in your DMS configuration.") + if offerReclone(dmsPath) { + return nil + } + return errdefs.ErrUpdateCancelled + } + + fmt.Printf("Updating to %s...\n", latestTag) + checkoutCmd := exec.Command("git", "checkout", latestTag) + checkoutCmd.Stdout = os.Stdout + checkoutCmd.Stderr = os.Stderr + if err := checkoutCmd.Run(); err != nil { + fmt.Printf("Error: Failed to checkout %s: %v\n", latestTag, err) + if offerReclone(dmsPath) { + return nil + } + return fmt.Errorf("update cancelled") + } + + fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag) + return nil + } + + if currentBranch == "" { + currentBranch = "master" + } + + fmt.Printf("Current branch: %s\n", currentBranch) + + if hasLocalChanges { + fmt.Println("\nWarning: You have local changes in your DMS configuration.") + if offerReclone(dmsPath) { + return nil + } + return errdefs.ErrUpdateCancelled + } + + pullCmd := exec.Command("git", "pull", "origin", currentBranch) + pullCmd.Stdout = os.Stdout + pullCmd.Stderr = os.Stderr + if err := pullCmd.Run(); err != nil { + fmt.Printf("Error: Failed to pull latest changes: %v\n", err) + if offerReclone(dmsPath) { + return nil + } + return fmt.Errorf("update cancelled") + } + + fmt.Println("\nUpdate complete!") + return nil +} + +func offerReclone(dmsPath string) bool { + fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ") + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(response)), "y") { + return false + } + + timestamp := time.Now().Unix() + backupPath := fmt.Sprintf("%s.backup-%d", dmsPath, timestamp) + + fmt.Printf("Backing up current directory to %s...\n", backupPath) + if err := os.Rename(dmsPath, backupPath); err != nil { + fmt.Printf("Error: Failed to backup directory: %v\n", err) + return false + } + + fmt.Println("Cloning fresh copy...") + cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath) + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + if err := cloneCmd.Run(); err != nil { + fmt.Printf("Error: Failed to clone repository: %v\n", err) + fmt.Printf("Restoring backup...\n") + os.Rename(backupPath, dmsPath) + return false + } + + fmt.Printf("Successfully re-cloned repository (backup at %s)\n", backupPath) + return true +} + +func confirmUpdate() bool { + fmt.Print("Do you want to proceed with the update? (y/N): ") + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + return false + } + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes" +} + +func updateDMSBinary() error { + arch := "" + switch strings.ToLower(os.Getenv("HOSTTYPE")) { + case "x86_64", "amd64": + arch = "amd64" + case "aarch64", "arm64": + arch = "arm64" + default: + cmd := exec.Command("uname", "-m") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to detect architecture: %w", err) + } + archStr := strings.TrimSpace(string(output)) + switch archStr { + case "x86_64": + arch = "amd64" + case "aarch64": + arch = "arm64" + default: + return fmt.Errorf("unsupported architecture: %s", archStr) + } + } + + fmt.Println("Fetching latest release version...") + cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to fetch latest release: %w", err) + } + + version := "" + for line := range strings.SplitSeq(string(output), "\n") { + if strings.Contains(line, "\"tag_name\"") { + parts := strings.Split(line, "\"") + if len(parts) >= 4 { + version = parts[3] + break + } + } + } + + if version == "" { + return fmt.Errorf("could not determine latest version") + } + + fmt.Printf("Latest version: %s\n", version) + + tempDir, err := os.MkdirTemp("", "dms-update-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch) + checksumURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch) + + binaryPath := filepath.Join(tempDir, "dms.gz") + checksumPath := filepath.Join(tempDir, "dms.gz.sha256") + + fmt.Println("Downloading dms binary...") + downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath) + if err := downloadCmd.Run(); err != nil { + return fmt.Errorf("failed to download binary: %w", err) + } + + fmt.Println("Downloading checksum...") + downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath) + if err := downloadCmd.Run(); err != nil { + return fmt.Errorf("failed to download checksum: %w", err) + } + + fmt.Println("Verifying checksum...") + checksumData, err := os.ReadFile(checksumPath) + if err != nil { + return fmt.Errorf("failed to read checksum file: %w", err) + } + expectedChecksum := strings.Fields(string(checksumData))[0] + + actualCmd := exec.Command("sha256sum", binaryPath) + actualOutput, err := actualCmd.Output() + if err != nil { + return fmt.Errorf("failed to calculate checksum: %w", err) + } + actualChecksum := strings.Fields(string(actualOutput))[0] + + if expectedChecksum != actualChecksum { + return fmt.Errorf("checksum verification failed\nExpected: %s\nGot: %s", expectedChecksum, actualChecksum) + } + + fmt.Println("Decompressing binary...") + decompressCmd := exec.Command("gunzip", binaryPath) + if err := decompressCmd.Run(); err != nil { + return fmt.Errorf("failed to decompress binary: %w", err) + } + + decompressedPath := filepath.Join(tempDir, "dms") + + if err := os.Chmod(decompressedPath, 0o755); err != nil { + return fmt.Errorf("failed to make binary executable: %w", err) + } + + currentPath, err := exec.LookPath("dms") + if err != nil { + return fmt.Errorf("could not find current dms binary: %w", err) + } + + fmt.Printf("Installing to %s...\n", currentPath) + + replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath) + replaceCmd.Stdin = os.Stdin + replaceCmd.Stdout = os.Stdout + replaceCmd.Stderr = os.Stderr + if err := replaceCmd.Run(); err != nil { + return fmt.Errorf("failed to replace binary: %w", err) + } + + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter.go new file mode 100644 index 0000000..1efa545 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter.go @@ -0,0 +1,1850 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/spf13/cobra" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var greeterCmd = &cobra.Command{ + Use: "greeter", + Short: "Manage DMS greeter", + Long: "Manage DMS greeter (greetd)", +} + +var ( + greeterConfigSyncFn = greeter.SyncDMSConfigs + sharedAuthSyncFn = sharedpam.SyncAuthConfig +) + +var greeterInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install and configure DMS greeter", + Long: "Install greetd and configure it to use DMS as the greeter interface", + PreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + installCmd := "dms greeter install" + if yes { + installCmd += " --yes" + } + installCmd += "; echo; echo \"Install finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(installCmd); err != nil { + log.Fatalf("Error launching install in terminal: %v", err) + } + return + } + if err := installGreeter(yes); err != nil { + log.Fatalf("Error installing greeter: %v", err) + } + }, +} + +var greeterSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync DMS theme and settings with greeter", + Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + auth, _ := cmd.Flags().GetBool("auth") + local, _ := cmd.Flags().GetBool("local") + term, _ := cmd.Flags().GetBool("terminal") + if term { + if err := syncInTerminal(yes, auth, local); err != nil { + log.Fatalf("Error launching sync in terminal: %v", err) + } + return + } + if err := syncGreeter(yes, auth, local); err != nil { + log.Fatalf("Error syncing greeter: %v", err) + } + }, +} + +func init() { + greeterSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts, use defaults (for UI)") + greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done") + greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles") + greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path") +} + +var greeterEnableCmd = &cobra.Command{ + Use: "enable", + Short: "Enable DMS greeter in greetd config", + Long: "Configure greetd to use DMS as the greeter", + PreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + enableCmd := "dms greeter enable" + if yes { + enableCmd += " --yes" + } + enableCmd += "; echo; echo \"Enable finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(enableCmd); err != nil { + log.Fatalf("Error launching enable in terminal: %v", err) + } + return + } + if err := enableGreeter(yes); err != nil { + log.Fatalf("Error enabling greeter: %v", err) + } + }, +} + +var greeterStatusCmd = &cobra.Command{ + Use: "status", + Short: "Check greeter sync status", + Long: "Check the status of greeter installation and configuration sync", + Run: func(cmd *cobra.Command, args []string) { + if err := checkGreeterStatus(); err != nil { + log.Fatalf("Error checking greeter status: %v", err) + } + }, +} + +var greeterUninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Remove DMS greeter configuration and restore previous display manager", + Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state", + PreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + uninstallCmd := "dms greeter uninstall" + if yes { + uninstallCmd += " --yes" + } + uninstallCmd += "; echo; echo \"Uninstall finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(uninstallCmd); err != nil { + log.Fatalf("Error launching uninstall in terminal: %v", err) + } + return + } + if err := uninstallGreeter(yes); err != nil { + log.Fatalf("Error uninstalling greeter: %v", err) + } + }, +} + +func init() { + greeterInstallCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterInstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") + greeterEnableCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterEnableCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") + greeterUninstallCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") +} + +func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error { + if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil { + return err + } + if beforeAuth != nil { + beforeAuth() + } + return sharedAuthSyncFn(logFunc, "", options) +} + +func installGreeter(nonInteractive bool) error { + fmt.Println("=== DMS Greeter Installation ===") + + logFunc := func(msg string) { + fmt.Println(msg) + } + + if !nonInteractive { + fmt.Print("\nThis will install greetd (if needed), configure the DMS greeter, and enable it. Continue? [Y/n]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) == "n" || strings.ToLower(strings.TrimSpace(response)) == "no" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + } + + if err := greeter.EnsureGreetdInstalled(logFunc, ""); err != nil { + return err + } + + greeter.TryInstallGreeterPackage(logFunc, "") + if isPackageOnlyGreeterDistro() && !greeter.IsGreeterPackaged() { + return fmt.Errorf("dms-greeter must be installed from distro packages on this distribution. %s", packageInstallHint()) + } + if greeter.IsGreeterPackaged() && greeter.HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + + if isGreeterEnabled() { + fmt.Print("\nGreeter is already installed and configured. Re-run to re-sync settings and permissions? [Y/n]: ") + var response string + fmt.Scanln(&response) + response = strings.TrimSpace(strings.ToLower(response)) + if response == "n" || response == "no" { + fmt.Println("Run 'dms greeter sync' to re-sync theme and settings at any time.") + return nil + } + fmt.Println() + } + + fmt.Println("\nDetecting DMS installation...") + dmsPath, err := greeter.DetectDMSPath() + if err != nil { + return err + } + fmt.Printf("✓ Found DMS at: %s\n", dmsPath) + + fmt.Println("\nDetecting installed compositors...") + compositors := greeter.DetectCompositors() + if len(compositors) == 0 { + return fmt.Errorf("no supported compositors found (niri or Hyprland required)") + } + + var selectedCompositor string + if len(compositors) == 1 { + selectedCompositor = compositors[0] + fmt.Printf("✓ Found compositor: %s\n", selectedCompositor) + } else { + var err error + selectedCompositor, err = greeter.PromptCompositorChoice(compositors) + if err != nil { + return err + } + fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor) + } + + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + + fmt.Println("\nCopying greeter files...") + if err := greeter.CopyGreeterFiles(dmsPath, selectedCompositor, logFunc, ""); err != nil { + return err + } + + if greeter.IsAppArmorEnabled() { + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + } + + fmt.Println("\nConfiguring greetd...") + greeterPathForConfig := "" + if !greeter.IsGreeterPackaged() { + greeterPathForConfig = dmsPath + } + if err := greeter.ConfigureGreetd(greeterPathForConfig, selectedCompositor, logFunc, ""); err != nil { + return err + } + + fmt.Println("\nSynchronizing DMS configurations...") + if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() { + fmt.Println("\nConfiguring authentication...") + }); err != nil { + return err + } + + if err := ensureGraphicalTarget(); err != nil { + return err + } + + if err := handleConflictingDisplayManagers(); err != nil { + return err + } + + if err := ensureGreetdEnabled(); err != nil { + return err + } + + fmt.Println("\n=== Installation Complete ===") + fmt.Println("\nTo start the greeter now, run:") + fmt.Println(" sudo systemctl start greetd") + fmt.Println("\nOr reboot to see the greeter at next boot.") + + return nil +} + +func uninstallGreeter(nonInteractive bool) error { + fmt.Println("=== DMS Greeter Uninstall ===") + + logFunc := func(msg string) { fmt.Println(msg) } + + if !isGreeterEnabled() { + fmt.Println("ℹ DMS greeter is not currently configured in /etc/greetd/config.toml.") + fmt.Println(" Nothing to undo for greetd configuration.") + } + + if !nonInteractive { + fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS-managed greeter auth block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) != "y" { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Println("\nDisabling greetd...") + disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd") + disableCmd.Stdout = os.Stdout + disableCmd.Stderr = os.Stderr + if err := disableCmd.Run(); err != nil { + fmt.Printf(" ⚠ Could not disable greetd: %v\n", err) + } else { + fmt.Println(" ✓ greetd disabled") + } + + fmt.Println("\nRemoving DMS authentication configuration...") + if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil { + fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err) + } + + fmt.Println("\nRemoving DMS AppArmor profile...") + if err := greeter.UninstallAppArmorProfile(logFunc, ""); err != nil { + fmt.Printf(" ⚠ AppArmor cleanup failed: %v\n", err) + } + + fmt.Println("\nRestoring greetd configuration...") + if err := restorePreDMSGreetdConfig(""); err != nil { + fmt.Printf(" ⚠ Could not restore previous greetd config: %v\n", err) + fmt.Println(" You may need to manually edit /etc/greetd/config.toml.") + } + + fmt.Println("\nChecking for other display managers to re-enable...") + suggestDisplayManagerRestore(nonInteractive) + + fmt.Println("\n=== Uninstall Complete ===") + fmt.Println("\nReboot to complete the uninstallation and switch to your previous display manager.") + fmt.Println("To re-enable DMS greeter at any time, run: dms greeter enable") + + return nil +} + +func restorePreDMSGreetdConfig(sudoPassword string) error { + const configPath = "/etc/greetd/config.toml" + const backupGlob = "/etc/greetd/config.toml.backup-*" + + matches, _ := filepath.Glob(backupGlob) + + for i := 0; i < len(matches)-1; i++ { + for j := i + 1; j < len(matches); j++ { + if matches[j] > matches[i] { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + for _, candidate := range matches { + data, err := os.ReadFile(candidate) + if err != nil { + continue + } + if strings.Contains(string(data), "dms-greeter") { + continue + } + tmp, err := os.CreateTemp("", "greetd-restore-*") + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + tmp.Close() + + if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + return fmt.Errorf("failed to restore %s: %w", candidate, err) + } + if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil { + return err + } + fmt.Printf(" ✓ Restored greetd config from %s\n", candidate) + return nil + } + + minimal := `[terminal] +vt = 1 + +# DMS greeter has been uninstalled. +# Configure a greeter command here or re-enable a display manager. +[default_session] +user = "greeter" +command = "agreety --cmd /bin/bash" +` + tmp, err := os.CreateTemp("", "greetd-minimal-*") + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.WriteString(minimal); err != nil { + tmp.Close() + return err + } + tmp.Close() + + if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + return fmt.Errorf("failed to write fallback greetd config: %w", err) + } + _ = runSudoCommand(sudoPassword, "chmod", "644", configPath) + fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)") + return nil +} + +func runSudoCommand(_ string, command string, args ...string) error { + cmd := exec.Command("sudo", append([]string{command}, args...)...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// suggestDisplayManagerRestore scans for installed DMs and re-enables one +func suggestDisplayManagerRestore(nonInteractive bool) { + knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} + var found []string + for _, dm := range knownDMs { + if utils.CommandExists(dm) || isSystemdUnitInstalled(dm) { + found = append(found, dm) + } + } + if len(found) == 0 { + fmt.Println(" ℹ No other display managers detected.") + fmt.Println(" You can install one (e.g. gdm, lightdm, sddm) and then run:") + fmt.Println(" sudo systemctl enable --now <dm-name>") + return + } + + enableDM := func(dm string) { + fmt.Printf(" Enabling %s...\n", dm) + cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err) + } else { + fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm) + } + } + + if len(found) == 1 || nonInteractive { + chosen := found[0] + if len(found) > 1 { + fmt.Printf(" ℹ Multiple display managers found (%s); enabling %s automatically.\n", + strings.Join(found, ", "), chosen) + } else { + fmt.Printf(" ℹ Found display manager: %s\n", chosen) + } + enableDM(chosen) + return + } + + fmt.Println(" ℹ Found the following display managers:") + for i, dm := range found { + fmt.Printf(" %d) %s\n", i+1, dm) + } + fmt.Print(" Choose a number to re-enable (or press Enter to skip): ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + fmt.Println(" Skipped. You can re-enable a display manager later with:") + fmt.Println(" sudo systemctl enable --now <dm-name>") + return + } + + n, err := strconv.Atoi(input) + if err != nil || n < 1 || n > len(found) { + fmt.Printf(" Invalid selection %q — skipping.\n", input) + return + } + + enableDM(found[n-1]) +} + +func isSystemdUnitInstalled(unit string) bool { + cmd := exec.Command("systemctl", "list-unit-files", unit+".service", "--no-legend", "--no-pager") + out, err := cmd.Output() + return err == nil && strings.Contains(string(out), unit) +} + +func runCommandInTerminal(shellCmd string) error { + terminals := []struct { + name string + args []string + }{ + {"gnome-terminal", []string{"--", "bash", "-c", shellCmd}}, + {"konsole", []string{"-e", "bash", "-c", shellCmd}}, + {"xfce4-terminal", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}}, + {"ghostty", []string{"-e", "bash", "-c", shellCmd}}, + {"wezterm", []string{"start", "--", "bash", "-c", shellCmd}}, + {"alacritty", []string{"-e", "bash", "-c", shellCmd}}, + {"kitty", []string{"bash", "-c", shellCmd}}, + {"xterm", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}}, + } + for _, t := range terminals { + if _, err := exec.LookPath(t.name); err != nil { + continue + } + cmd := exec.Command(t.name, t.args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + return nil + } + return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)") +} + +func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error { + syncFlags := make([]string, 0, 3) + if nonInteractive { + syncFlags = append(syncFlags, "--yes") + } + if forceAuth { + syncFlags = append(syncFlags, "--auth") + } + if local { + syncFlags = append(syncFlags, "--local") + } + shellSyncCmd := "dms greeter sync" + if len(syncFlags) > 0 { + shellSyncCmd += " " + strings.Join(syncFlags, " ") + } + shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` + return runCommandInTerminal(shellCmd) +} + +func resolveLocalWrapperShell() (string, error) { + for _, shellName := range []string{"bash", "sh"} { + shellPath, err := exec.LookPath(shellName) + if err == nil { + return shellPath, nil + } + } + return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper") +} + +func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { + if !nonInteractive { + fmt.Println("=== DMS Greeter Sync ===") + fmt.Println() + } + + logFunc := func(msg string) { + fmt.Println(msg) + } + + if !nonInteractive { + fmt.Println("Detecting DMS installation...") + } + var dmsPath string + var err error + if local { + dmsPath, err = resolveLocalDMSPath() + if err != nil { + return err + } + if !nonInteractive { + fmt.Printf("✓ Using local DMS path: %s\n", dmsPath) + } + } else { + dmsPath, err = greeter.DetectDMSPath() + if err != nil { + return err + } + if !nonInteractive { + fmt.Printf("✓ Found DMS at: %s\n", dmsPath) + } + } + + if !isGreeterEnabled() { + if nonInteractive { + return fmt.Errorf("greeter is not enabled; run 'dms greeter install' or 'dms greeter enable' first") + } + fmt.Println("\n⚠ DMS greeter is not enabled in greetd config.") + fmt.Print("Would you like to enable it now? (Y/n): ") + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "n" && response != "no" { + if err := enableGreeter(false); err != nil { + return err + } + } else { + return fmt.Errorf("greeter must be enabled before syncing") + } + } + + if greeter.IsGreeterPackaged() && greeter.HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + + cacheDir := greeter.GreeterCacheDir + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + logFunc("Cache directory not found — attempting to create it...") + } + + greeterGroup := greeter.DetectGreeterGroup() + greeterGroupExists := utils.HasGroup(greeterGroup) + if greeterGroupExists { + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + groupsCmd := exec.Command("groups", currentUser.Username) + groupsOutput, err := groupsCmd.Output() + if err != nil { + return fmt.Errorf("failed to check groups: %w", err) + } + + inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup) + if !inGreeterGroup { + if nonInteractive { + logFunc(fmt.Sprintf("⚠ Not yet in %s group — will be added during sync (logout/login required to take effect).", greeterGroup)) + } else { + fmt.Printf("\n⚠ Warning: You are not in the %s group.\n", greeterGroup) + fmt.Printf("Would you like to add your user to the %s group? (Y/n): ", greeterGroup) + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "n" && response != "no" { + fmt.Printf("\nAdding user to %s group...\n", greeterGroup) + addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username) + addUserCmd.Stdout = os.Stdout + addUserCmd.Stderr = os.Stderr + if err := addUserCmd.Run(); err != nil { + return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) + } + fmt.Printf("✓ User added to %s group\n", greeterGroup) + fmt.Println("⚠ You will need to log out and back in for the group change to take effect") + } else { + return fmt.Errorf("aborted: user must be in the greeter group before syncing") + } + } + } + } + + compositor := detectConfiguredCompositor() + if compositor == "" { + compositors := greeter.DetectCompositors() + switch len(compositors) { + case 0: + return fmt.Errorf("no supported compositors found") + case 1: + compositor = compositors[0] + if !nonInteractive { + fmt.Printf("✓ Using compositor: %s\n", compositor) + } + default: + if nonInteractive { + compositor = compositors[0] + break + } + var err error + compositor, err = promptCompositorChoice(compositors) + if err != nil { + return err + } + fmt.Printf("✓ Selected compositor: %s\n", compositor) + } + } else if !nonInteractive { + fmt.Printf("✓ Detected compositor from config: %s\n", compositor) + } + + if local { + localWrapperScript := filepath.Join(dmsPath, "Modules", "Greetd", "assets", "dms-greeter") + restoreWrapperOverride := func() {} + if info, statErr := os.Stat(localWrapperScript); statErr == nil && !info.IsDir() { + wrapperShell, shellErr := resolveLocalWrapperShell() + if shellErr != nil { + return shellErr + } + previousWrapperOverride, hadWrapperOverride := os.LookupEnv("DMS_GREETER_WRAPPER_CMD") + wrapperCmdOverride := wrapperShell + " " + localWrapperScript + _ = os.Setenv("DMS_GREETER_WRAPPER_CMD", wrapperCmdOverride) + restoreWrapperOverride = func() { + if hadWrapperOverride { + _ = os.Setenv("DMS_GREETER_WRAPPER_CMD", previousWrapperOverride) + } else { + _ = os.Unsetenv("DMS_GREETER_WRAPPER_CMD") + } + } + if !nonInteractive { + fmt.Printf("✓ Using local greeter wrapper script: %s\n", localWrapperScript) + } + } else if !nonInteractive { + fmt.Printf("ℹ Local wrapper script not found at %s; using system wrapper.\n", localWrapperScript) + } + + fmt.Println("\nUpdating greetd command to use local DMS path...") + err := greeter.ConfigureGreetd(dmsPath, compositor, logFunc, "") + restoreWrapperOverride() + if err != nil { + return fmt.Errorf("failed to apply local greeter path: %w", err) + } + if !nonInteractive { + fmt.Println("ℹ Local mode applies both DMS path override (-p) and local wrapper behavior when available.") + } + } else { + greeterPathForConfig := "" + if !greeter.IsGreeterPackaged() { + greeterPathForConfig = dmsPath + } + fmt.Println("\nUpdating greetd command...") + if err := greeter.ConfigureGreetd(greeterPathForConfig, compositor, logFunc, ""); err != nil { + return fmt.Errorf("failed to update greetd command: %w", err) + } + } + + fmt.Println("\nSetting up permissions and ACLs...") + greeter.RemediateStaleACLs(logFunc, "") + greeter.RemediateStaleAppArmor(logFunc, "") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { + return fmt.Errorf("failed to ensure greeter cache directory at %s: %w\nRun: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s", cacheDir, err, cacheDir, greeterGroup, cacheDir, cacheDir) + } + + fmt.Println("\nSynchronizing DMS configurations...") + if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{ + ForceGreeterAuth: forceAuth, + }, func() { + fmt.Println("\nConfiguring authentication...") + }); err != nil { + return err + } + + if greeter.IsAppArmorEnabled() { + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + } + + fmt.Println("\n=== Sync Complete ===") + fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.") + fmt.Println("Shared authentication settings were also checked and reconciled where needed.") + if forceAuth { + fmt.Println("Authentication has been configured for fingerprint and U2F (where modules exist).") + } + fmt.Println("The changes will be visible on the next login screen.") + + return nil +} + +func hasDmsShellQml(dir string) bool { + info, err := os.Stat(filepath.Join(dir, "shell.qml")) + return err == nil && !info.IsDir() +} + +func resolveDMSLocalCandidate(path string) (string, bool) { + if path == "" { + return "", false + } + if hasDmsShellQml(path) { + abs, err := filepath.Abs(path) + if err != nil { + return path, true + } + return abs, true + } + + quickshellPath := filepath.Join(path, "quickshell") + if hasDmsShellQml(quickshellPath) { + abs, err := filepath.Abs(quickshellPath) + if err != nil { + return quickshellPath, true + } + return abs, true + } + + return "", false +} + +func resolveLocalDMSPath() (string, error) { + if override := strings.TrimSpace(os.Getenv("DMS_LOCAL_PATH")); override != "" { + if resolved, ok := resolveDMSLocalCandidate(override); ok { + return resolved, nil + } + return "", fmt.Errorf("DMS_LOCAL_PATH is set but does not point to a valid DMS quickshell path: %s", override) + } + + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + dir := wd + for { + if resolved, ok := resolveDMSLocalCandidate(dir); ok { + return resolved, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + homeDir, err := os.UserHomeDir() + if err == nil && homeDir != "" { + for _, candidate := range []string{ + filepath.Join(homeDir, "dms"), + filepath.Join(homeDir, "DankMaterialShell"), + filepath.Join(homeDir, "dankmaterialshell"), + filepath.Join(homeDir, "projects", "dms"), + filepath.Join(homeDir, "src", "dms"), + } { + if resolved, ok := resolveDMSLocalCandidate(candidate); ok { + return resolved, nil + } + } + + if entries, readErr := os.ReadDir(homeDir); readErr == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := strings.ToLower(entry.Name()) + if !strings.Contains(name, "dms") && !strings.Contains(name, "dank") { + continue + } + if resolved, ok := resolveDMSLocalCandidate(filepath.Join(homeDir, entry.Name())); ok { + return resolved, nil + } + } + } + } + + return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd) +} + +func disableDisplayManager(dmName string) (bool, error) { + state, err := getSystemdServiceState(dmName) + if err != nil { + return false, fmt.Errorf("failed to check %s state: %w", dmName, err) + } + + if !state.Exists { + return false, nil + } + + fmt.Printf("\nChecking %s...\n", dmName) + fmt.Printf(" Current state: enabled=%s\n", state.EnabledState) + + actionTaken := false + + if state.NeedsDisable { + var disableCmd *exec.Cmd + var actionVerb string + + if state.EnabledState == "static" { + fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName) + disableCmd = exec.Command("sudo", "systemctl", "mask", dmName) + actionVerb = "masked" + } else { + fmt.Printf(" Disabling %s...\n", dmName) + disableCmd = exec.Command("sudo", "systemctl", "disable", dmName) + actionVerb = "disabled" + } + + disableCmd.Stdout = os.Stdout + disableCmd.Stderr = os.Stderr + if err := disableCmd.Run(); err != nil { + return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err) + } + + enabledState, shouldDisable, verifyErr := checkSystemdServiceEnabled(dmName) + if verifyErr != nil { + fmt.Printf(" ⚠ Warning: Could not verify %s was %s: %v\n", dmName, actionVerb, verifyErr) + } else if shouldDisable { + return actionTaken, fmt.Errorf("%s is still in state '%s' after %s operation", dmName, enabledState, actionVerb) + } else { + fmt.Printf(" ✓ %s %s (now: %s)\n", cases.Title(language.English).String(actionVerb), dmName, enabledState) + } + + actionTaken = true + } else { + if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { + fmt.Printf(" ✓ %s is already masked\n", dmName) + } else { + fmt.Printf(" ✓ %s is already disabled\n", dmName) + } + } + + return actionTaken, nil +} + +func ensureGreetdEnabled() error { + fmt.Println("\nChecking greetd service status...") + + state, err := getSystemdServiceState("greetd") + if err != nil { + return fmt.Errorf("failed to check greetd state: %w", err) + } + + if !state.Exists { + return fmt.Errorf("greetd service not found. Please install greetd first") + } + + fmt.Printf(" Current state: %s\n", state.EnabledState) + + if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { + fmt.Println(" Unmasking greetd...") + unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd") + unmaskCmd.Stdout = os.Stdout + unmaskCmd.Stderr = os.Stderr + if err := unmaskCmd.Run(); err != nil { + return fmt.Errorf("failed to unmask greetd: %w", err) + } + fmt.Println(" ✓ Unmasked greetd") + } + + if state.EnabledState == "enabled" || state.EnabledState == "enabled-runtime" { + fmt.Println(" Reasserting greetd as active display manager...") + } else { + fmt.Println(" Enabling greetd service...") + } + + enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd") + enableCmd.Stdout = os.Stdout + enableCmd.Stderr = os.Stderr + if err := enableCmd.Run(); err != nil { + return fmt.Errorf("failed to enable greetd: %w", err) + } + + enabledState, _, verifyErr := checkSystemdServiceEnabled("greetd") + if verifyErr != nil { + fmt.Printf(" ⚠ Warning: Could not verify greetd enabled state: %v\n", verifyErr) + } else { + switch enabledState { + case "enabled", "enabled-runtime", "static", "indirect", "alias": + fmt.Printf(" ✓ greetd enabled (state: %s)\n", enabledState) + default: + return fmt.Errorf("greetd is still in state '%s' after enable operation", enabledState) + } + } + + return nil +} + +func ensureGraphicalTarget() error { + getDefaultCmd := exec.Command("systemctl", "get-default") + currentTarget, err := getDefaultCmd.Output() + if err != nil { + fmt.Println("⚠ Warning: Could not detect current default systemd target") + return nil + } + + currentTargetStr := strings.TrimSpace(string(currentTarget)) + if currentTargetStr != "graphical.target" { + fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr) + setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target") + setDefaultCmd.Stdout = os.Stdout + setDefaultCmd.Stderr = os.Stderr + if err := setDefaultCmd.Run(); err != nil { + fmt.Println("⚠ Warning: Failed to set graphical.target as default") + fmt.Println(" Greeter may not start on boot. Run manually:") + fmt.Println(" sudo systemctl set-default graphical.target") + return nil + } + fmt.Println("✓ Set graphical.target as default") + } else { + fmt.Println("✓ Default target already set to graphical.target") + } + + return nil +} + +func handleConflictingDisplayManagers() error { + fmt.Println("\n=== Checking for Conflicting Display Managers ===") + + conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} + + disabledAny := false + var errors []string + + for _, dm := range conflictingDMs { + actionTaken, err := disableDisplayManager(dm) + if err != nil { + errMsg := fmt.Sprintf("Failed to handle %s: %v", dm, err) + errors = append(errors, errMsg) + fmt.Printf(" ⚠⚠⚠ ERROR: %s\n", errMsg) + continue + } + if actionTaken { + disabledAny = true + } + } + + if len(errors) > 0 { + fmt.Println("\n╔════════════════════════════════════════════════════════════╗") + fmt.Println("║ ⚠⚠⚠ ERRORS OCCURRED ⚠⚠⚠ ║") + fmt.Println("╚════════════════════════════════════════════════════════════╝") + fmt.Println("\nSome display managers could not be disabled:") + for _, err := range errors { + fmt.Printf(" ✗ %s\n", err) + } + fmt.Println("\nThis may prevent greetd from starting properly.") + fmt.Println("You may need to manually disable them before greetd will work.") + fmt.Println("\nManual commands to try:") + for _, dm := range conflictingDMs { + fmt.Printf(" sudo systemctl disable %s\n", dm) + fmt.Printf(" sudo systemctl mask %s\n", dm) + } + fmt.Print("\nContinue with greeter enablement anyway? (Y/n): ") + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response == "n" || response == "no" { + return fmt.Errorf("aborted due to display manager conflicts") + } + fmt.Println("\nContinuing despite errors...") + } + + if !disabledAny && len(errors) == 0 { + fmt.Println("\n✓ No conflicting display managers found") + } else if disabledAny && len(errors) == 0 { + fmt.Println("\n✓ Successfully handled all conflicting display managers") + } + + return nil +} + +func enableGreeter(nonInteractive bool) error { + fmt.Println("=== DMS Greeter Enable ===") + fmt.Println() + + configPath := "/etc/greetd/config.toml" + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("greetd config not found at %s\nPlease install greetd first", configPath) + } else if err != nil { + return fmt.Errorf("failed to access greetd config at %s: %w", configPath, err) + } + + if greeter.IsGreeterPackaged() && greeter.HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + + configAlreadyCorrect := isGreeterEnabled() + configuredCompositor := detectConfiguredCompositor() + + logFunc := func(msg string) { + fmt.Println(msg) + } + greeterGroup := greeter.DetectGreeterGroup() + + if configAlreadyCorrect { + fmt.Println("✓ Greeter is already configured with dms-greeter") + if configuredCompositor != "" { + fmt.Printf("✓ Configured compositor: %s\n", configuredCompositor) + } + + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { + fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir) + } + + if err := ensureGraphicalTarget(); err != nil { + return err + } + + if err := handleConflictingDisplayManagers(); err != nil { + return err + } + + if err := ensureGreetdEnabled(); err != nil { + return err + } + + fmt.Println("\n=== Enable Complete ===") + fmt.Println("\nGreeter configuration verified and system state corrected.") + fmt.Println("To start the greeter now, run:") + fmt.Println(" sudo systemctl start greetd") + fmt.Println("\nOr reboot to see the greeter at boot time.") + + return nil + } + + if !nonInteractive { + fmt.Print("\nThis will configure greetd to use the DMS greeter and may disable other display managers. Continue? [Y/n]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) == "n" || strings.ToLower(strings.TrimSpace(response)) == "no" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + } + + fmt.Println("Detecting installed compositors...") + compositors := greeter.DetectCompositors() + + if utils.CommandExists("sway") { + compositors = append(compositors, "sway") + } + + if len(compositors) == 0 { + return fmt.Errorf("no supported compositors found (niri, Hyprland, or sway required)") + } + + var selectedCompositor string + if len(compositors) == 1 { + selectedCompositor = compositors[0] + fmt.Printf("✓ Found compositor: %s\n", selectedCompositor) + } else { + var err error + selectedCompositor, err = promptCompositorChoice(compositors) + if err != nil { + return err + } + fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor) + } + + greeterPathForConfig := "" + if !greeter.IsGreeterPackaged() { + dmsPath, err := greeter.DetectDMSPath() + if err != nil { + return fmt.Errorf("failed to detect DMS path for manual greeter configuration: %w", err) + } + greeterPathForConfig = dmsPath + } + if err := greeter.ConfigureGreetd(greeterPathForConfig, selectedCompositor, logFunc, ""); err != nil { + return fmt.Errorf("failed to configure greetd: %w", err) + } + + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { + fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir) + } + + if greeter.IsAppArmorEnabled() { + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + } + + if err := ensureGraphicalTarget(); err != nil { + return err + } + + if err := handleConflictingDisplayManagers(); err != nil { + return err + } + + if err := ensureGreetdEnabled(); err != nil { + return err + } + + fmt.Println("\n=== Enable Complete ===") + fmt.Println("\nTo start the greeter now, run:") + fmt.Println(" sudo systemctl start greetd") + fmt.Println("\nOr reboot to see the greeter at boot time.") + + return nil +} + +func isGreeterEnabled() bool { + command := readDefaultSessionCommand("/etc/greetd/config.toml") + return command != "" && strings.Contains(command, "dms-greeter") +} + +func detectConfiguredCompositor() string { + command := strings.ToLower(readDefaultSessionCommand("/etc/greetd/config.toml")) + switch { + case strings.Contains(command, "--command niri"): + return "niri" + case strings.Contains(command, "--command hyprland"): + return "hyprland" + case strings.Contains(command, "--command sway"): + return "sway" + } + return "" +} + +func stripTomlComment(line string) string { + trimmed := strings.TrimSpace(line) + if idx := strings.Index(trimmed, "#"); idx >= 0 { + return strings.TrimSpace(trimmed[:idx]) + } + return trimmed +} + +func parseTomlSection(line string) (string, bool) { + trimmed := stripTomlComment(line) + if len(trimmed) < 3 || !strings.HasPrefix(trimmed, "[") || !strings.HasSuffix(trimmed, "]") { + return "", false + } + return strings.TrimSpace(trimmed[1 : len(trimmed)-1]), true +} + +func readDefaultSessionCommand(configPath string) string { + data, err := os.ReadFile(configPath) + if err != nil { + return "" + } + + inDefaultSession := false + for line := range strings.SplitSeq(string(data), "\n") { + if section, ok := parseTomlSection(line); ok { + inDefaultSession = section == "default_session" + continue + } + + if !inDefaultSession { + continue + } + + trimmed := stripTomlComment(line) + if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + + command := strings.Trim(strings.TrimSpace(parts[1]), `"`) + if command != "" { + return command + } + } + + return "" +} + +func extractGreeterCacheDirFromCommand(command string) string { + if command == "" { + return greeter.GreeterCacheDir + } + tokens := strings.Fields(command) + for i := 0; i < len(tokens); i++ { + token := strings.Trim(tokens[i], "\"") + if token == "--cache-dir" && i+1 < len(tokens) { + return strings.Trim(tokens[i+1], "\"") + } + if strings.HasPrefix(token, "--cache-dir=") { + value := strings.TrimPrefix(token, "--cache-dir=") + value = strings.Trim(value, "\"") + if value != "" { + return value + } + } + } + return greeter.GreeterCacheDir +} + +func extractGreeterWrapperFromCommand(command string) string { + if command == "" { + return "" + } + tokens := strings.Fields(command) + if len(tokens) == 0 { + return "" + } + wrapper := strings.Trim(tokens[0], "\"") + if wrapper == "" { + return "" + } + if len(tokens) > 1 { + next := strings.Trim(tokens[1], "\"") + if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") { + return fmt.Sprintf("%s (script: %s)", wrapper, next) + } + } + return wrapper +} + +func extractGreeterPathOverrideFromCommand(command string) string { + if command == "" { + return "" + } + tokens := strings.Fields(command) + for i := 0; i < len(tokens); i++ { + token := strings.Trim(tokens[i], "\"") + if (token == "-p" || token == "--path") && i+1 < len(tokens) { + return strings.Trim(tokens[i+1], "\"") + } + if strings.HasPrefix(token, "--path=") { + value := strings.TrimPrefix(token, "--path=") + value = strings.Trim(value, "\"") + if value != "" { + return value + } + } + } + return "" +} + +func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { + return sharedpam.ParseManagedGreeterPamAuth(pamText) +} + +func packageInstallHint() string { + osInfo, err := distros.GetOSInfo() + if err != nil { + return "Install package: dms-greeter" + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return "Install package: dms-greeter" + } + + switch config.Family { + case distros.FamilyDebian: + return "Install with 'sudo apt install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#debian)" + case distros.FamilySUSE: + return "Install with 'sudo zypper install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#opensuse)" + case distros.FamilyUbuntu: + return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)" + case distros.FamilyFedora: + return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)" + case distros.FamilyArch: + return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'" + default: + return "Run 'dms greeter install' to install greeter" + } +} + +func systemPamManagerRemediationHint() string { + osInfo, err := distros.GetOSInfo() + if err != nil { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + + switch config.Family { + case distros.FamilyFedora: + return "Disable it in authselect to force password-only greeter login." + case distros.FamilyDebian, distros.FamilyUbuntu: + return "Disable it in pam-auth-update to force password-only greeter login." + default: + return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } +} + +func isPackageOnlyGreeterDistro() bool { + osInfo, err := distros.GetOSInfo() + if err != nil { + return false + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return false + } + return config.Family == distros.FamilyDebian || + config.Family == distros.FamilySUSE || + config.Family == distros.FamilyUbuntu || + config.Family == distros.FamilyFedora || + config.Family == distros.FamilyArch +} + +func promptCompositorChoice(compositors []string) (string, error) { + fmt.Println("\nMultiple compositors detected:") + for i, comp := range compositors { + fmt.Printf("%d) %s\n", i+1, comp) + } + + var response string + fmt.Print("Choose compositor for greeter: ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + choice := 0 + fmt.Sscanf(response, "%d", &choice) + + if choice < 1 || choice > len(compositors) { + return "", fmt.Errorf("invalid choice") + } + + return compositors[choice-1], nil +} + +func checkGreeterStatus() error { + fmt.Println("=== DMS Greeter Status ===") + fmt.Println() + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + configPath := "/etc/greetd/config.toml" + configuredCommand := "" + allGood := true + fmt.Println("Greeter Configuration:") + if _, err := os.ReadFile(configPath); err == nil { + configuredCommand = readDefaultSessionCommand(configPath) + if configuredCommand != "" && strings.Contains(configuredCommand, "dms-greeter") { + fmt.Println(" ✓ Greeter is enabled") + if wrapper := extractGreeterWrapperFromCommand(configuredCommand); wrapper != "" { + fmt.Printf(" Wrapper: %s\n", wrapper) + } + if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" { + fmt.Printf(" DMS path override: %s\n", pathOverride) + } + + compositor := detectConfiguredCompositor() + switch compositor { + case "niri": + fmt.Println(" Compositor: niri") + case "hyprland": + fmt.Println(" Compositor: Hyprland") + case "sway": + fmt.Println(" Compositor: sway") + default: + fmt.Println(" Compositor: unknown") + } + } else { + fmt.Println(" ✗ Greeter is NOT enabled") + fmt.Println(" Run 'dms greeter enable' to enable it, or use the Activate button in Settings → Greeter, then Sync.") + allGood = false + } + } else { + fmt.Println(" ✗ Greeter config not found") + fmt.Printf(" %s\n", packageInstallHint()) + allGood = false + } + + fmt.Println("\nGroup Membership:") + groupsCmd := exec.Command("groups", currentUser.Username) + groupsOutput, err := groupsCmd.Output() + if err != nil { + return fmt.Errorf("failed to check groups: %w", err) + } + + greeterGroup := greeter.DetectGreeterGroup() + inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup) + if inGreeterGroup { + fmt.Printf(" ✓ User is in %s group\n", greeterGroup) + } else { + fmt.Printf(" ✗ User is NOT in %s group\n", greeterGroup) + fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions") + } + + cacheDir := extractGreeterCacheDirFromCommand(configuredCommand) + fmt.Println("\nGreeter Cache Directory:") + fmt.Printf(" Effective cache dir: %s\n", cacheDir) + if cacheDir != greeter.GreeterCacheDir { + fmt.Printf(" ⚠ Non-default cache dir detected (default: %s)\n", greeter.GreeterCacheDir) + } + if stat, err := os.Stat(cacheDir); err == nil && stat.IsDir() { + fmt.Printf(" ✓ %s exists\n", cacheDir) + requiredSubdirs := []string{".local/state", ".local/share", ".cache"} + missingSubdirs := false + for _, sub := range requiredSubdirs { + subPath := filepath.Join(cacheDir, sub) + if _, err := os.Stat(subPath); os.IsNotExist(err) { + fmt.Printf(" ⚠ Missing required subdir: %s\n", subPath) + missingSubdirs = true + } + } + if missingSubdirs { + fmt.Println(" Run 'dms greeter sync' to initialize the cache directory structure.") + allGood = false + } + } else { + fmt.Printf(" ✗ %s not found\n", cacheDir) + fmt.Printf(" %s\n", packageInstallHint()) + return nil + } + + fmt.Println("\nConfiguration Symlinks:") + colorSyncInfo, colorSyncErr := greeter.ResolveGreeterColorSyncInfo(homeDir) + if colorSyncErr != nil { + fmt.Printf(" ✗ Failed to resolve expected greeter color source: %v\n", colorSyncErr) + allGood = false + colorSyncInfo = greeter.GreeterColorSyncInfo{ + SourcePath: filepath.Join(homeDir, ".cache", "DankMaterialShell", "dms-colors.json"), + } + } + + colorThemeDesc := "Color theme" + if colorSyncInfo.UsesDynamicWallpaperOverride { + colorThemeDesc = "Color theme (greeter wallpaper override)" + } + + symlinks := []struct { + source string + target string + desc string + }{ + { + source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), + target: filepath.Join(cacheDir, "settings.json"), + desc: "Settings", + }, + { + source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), + target: filepath.Join(cacheDir, "session.json"), + desc: "Session state", + }, + { + source: colorSyncInfo.SourcePath, + target: filepath.Join(cacheDir, "colors.json"), + desc: colorThemeDesc, + }, + } + + for _, link := range symlinks { + targetInfo, err := os.Lstat(link.target) + if err != nil { + fmt.Printf(" ✗ %s: symlink not found at %s\n", link.desc, link.target) + allGood = false + continue + } + + if targetInfo.Mode()&os.ModeSymlink == 0 { + fmt.Printf(" ✗ %s: %s is not a symlink\n", link.desc, link.target) + allGood = false + continue + } + + linkDest, err := os.Readlink(link.target) + if err != nil { + fmt.Printf(" ✗ %s: failed to read symlink\n", link.desc) + allGood = false + continue + } + + if linkDest != link.source { + fmt.Printf(" ✗ %s: symlink points to wrong location\n", link.desc) + fmt.Printf(" Expected: %s\n", link.source) + fmt.Printf(" Got: %s\n", linkDest) + allGood = false + continue + } + + if _, err := os.Stat(link.source); os.IsNotExist(err) { + fmt.Printf(" ⚠ %s: symlink OK, but source file doesn't exist yet\n", link.desc) + fmt.Printf(" Will be created when you run DMS\n") + continue + } + + fmt.Printf(" ✓ %s: synced correctly\n", link.desc) + } + + if colorSyncInfo.UsesDynamicWallpaperOverride { + fmt.Printf(" ℹ Dynamic theme uses greeter override colors from %s\n", colorSyncInfo.SourcePath) + } + + fmt.Println("\nGreeter Wallpaper Override:") + overridePath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") + if stat, err := os.Stat(overridePath); err == nil && !stat.IsDir() { + fmt.Printf(" ✓ Override file present: %s\n", overridePath) + } else if os.IsNotExist(err) { + fmt.Println(" ℹ Override file not present (desktop/session wallpaper fallback in effect)") + } else if err != nil { + fmt.Printf(" ✗ Could not inspect override file: %v\n", err) + allGood = false + } else { + fmt.Printf(" ✗ Override path is not a regular file: %s\n", overridePath) + allGood = false + } + + fmt.Println("\nGreeter PAM Authentication (DMS-managed block):") + if greeter.IsNixOS() { + fmt.Println(" ℹ NixOS detected: PAM is managed by NixOS modules.") + fmt.Println(" Configure fingerprint/U2F via your greetd NixOS module (security.pam.services.greetd).") + fmt.Println() + if allGood && inGreeterGroup { + fmt.Println("✓ All checks passed! Greeter is properly configured.") + } else if !allGood { + fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to repair configuration.") + } else if !inGreeterGroup { + fmt.Printf("⚠ User is not in %s group. Run 'dms greeter sync' after adding group membership.\n", greeterGroup) + } + return nil + } + greetdPamPath := "/etc/pam.d/greetd" + pamData, err := os.ReadFile(greetdPamPath) + if err != nil { + fmt.Printf(" ✗ Failed to read %s: %v\n", greetdPamPath, err) + allGood = false + } else { + managed, managedFprint, managedU2f, legacyManaged := parseManagedGreeterPamAuth(string(pamData)) + if managed { + fmt.Println(" ✓ Managed auth block present") + if managedFprint { + fmt.Println(" - fingerprint: enabled") + } else { + fmt.Println(" - fingerprint: disabled") + } + if managedU2f { + fmt.Println(" - security key (U2F): enabled") + } else { + fmt.Println(" - security key (U2F): disabled") + } + } else { + fmt.Println(" ℹ No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)") + } + if legacyManaged { + fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms auth sync' to normalize.") + allGood = false + } + enableFprintToggle, enableU2fToggle := false, false + if enableFprint, enableU2f, settingsErr := sharedpam.ReadGreeterAuthToggles(homeDir); settingsErr == nil { + enableFprintToggle = enableFprint + enableU2fToggle = enableU2f + } else { + fmt.Printf(" ℹ Could not read greeter auth toggles from settings: %v\n", settingsErr) + } + + includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so") + includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so") + fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser() + + if managedFprint && includedFprintFile != "" { + fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile) + fmt.Println(" Double fingerprint auth detected — run 'dms auth sync' to resolve.") + allGood = false + } + if managedU2f && includedU2fFile != "" { + fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile) + fmt.Println(" Double security-key auth detected — run 'dms auth sync' to resolve.") + allGood = false + } + + if includedFprintFile != "" && !managedFprint { + if enableFprintToggle { + fmt.Printf(" ℹ Fingerprint auth is enabled via included %s.\n", includedFprintFile) + if fprintAvailableForCurrentUser { + fmt.Println(" DMS toggle is enabled, and effective auth is coming from the included PAM stack.") + } else { + fmt.Println(" No enrolled fingerprints detected for the current user; password auth remains the effective path.") + } + } else { + if fprintAvailableForCurrentUser { + fmt.Printf(" ℹ Fingerprint auth is active via included %s while DMS fingerprint toggle is off.\n", includedFprintFile) + fmt.Println(" Password login will work but may be delayed while the fingerprint module runs first.") + fmt.Printf(" To eliminate the delay, %s\n", systemPamManagerRemediationHint()) + } else { + fmt.Printf(" ℹ pam_fprintd is present via included %s, but no enrolled fingerprints were detected for user %s.\n", includedFprintFile, currentUser.Username) + fmt.Println(" Password auth remains the effective login path.") + } + } + } + if includedU2fFile != "" && !managedU2f { + if enableU2fToggle { + fmt.Printf(" ℹ Security-key auth is enabled via included %s.\n", includedU2fFile) + fmt.Println(" DMS toggle is enabled, but effective auth is coming from the included PAM stack.") + } else { + fmt.Printf(" ⚠ Security-key auth is active via included %s while DMS security-key toggle is off.\n", includedU2fFile) + fmt.Printf(" %s\n", systemPamManagerRemediationHint()) + } + } + } + + fmt.Println("\nSecurity (AppArmor):") + if !greeter.IsAppArmorEnabled() { + fmt.Println(" ℹ AppArmor not enabled") + } else { + fmt.Println(" ℹ AppArmor is enabled") + + const appArmorProfilePath = "/etc/apparmor.d/usr.bin.dms-greeter" + if _, err := os.Stat(appArmorProfilePath); os.IsNotExist(err) { + fmt.Println(" ⚠ DMS AppArmor profile not installed") + fmt.Println(" Run 'dms greeter sync' to install it and prevent potential TTY fallback") + allGood = false + } else { + mode := appArmorProfileMode("dms-greeter") + if mode != "" { + fmt.Printf(" ✓ DMS AppArmor profile installed (%s mode)\n", mode) + } else { + fmt.Println(" ✓ DMS AppArmor profile installed") + } + } + + denialCount, denialSamples, denialErr := recentAppArmorGreeterDenials(3) + if denialErr != nil { + fmt.Printf(" ℹ Could not inspect AppArmor denials automatically: %v\n", denialErr) + fmt.Println(" If greetd falls back to TTY, run: sudo journalctl -b -k | grep 'apparmor.*DENIED'") + } else if denialCount > 0 { + fmt.Printf(" ⚠ Found %d recent AppArmor denial(s) related to greeter runtime.\n", denialCount) + fmt.Println(" This can cause greetd fallback to TTY (for example: 'Failed to create stream fd: Permission denied').") + fmt.Println(" Review denials with: sudo journalctl -b -k | grep 'apparmor.*DENIED'") + fmt.Println(" Then refine the profile with: sudo aa-logprof") + for i, sample := range denialSamples { + fmt.Printf(" %d) %s\n", i+1, sample) + } + allGood = false + } else { + fmt.Println(" ✓ No recent AppArmor denials detected for common greeter components") + } + } + + fmt.Println() + if allGood && inGreeterGroup { + fmt.Println("✓ All checks passed! Greeter is properly configured.") + } else if !allGood { + fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to repair configuration.") + } else if !inGreeterGroup { + fmt.Printf("⚠ User is not in %s group. Run 'dms greeter sync' after adding group membership.\n", greeterGroup) + } + + return nil +} + +func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) { + if sampleLimit <= 0 { + sampleLimit = 3 + } + if !utils.CommandExists("journalctl") { + return 0, nil, fmt.Errorf("journalctl not found") + } + + queries := [][]string{ + {"-b", "-k", "--no-pager", "-n", "2000", "-o", "cat"}, + {"-b", "--no-pager", "-n", "2000", "-o", "cat"}, + } + + seen := make(map[string]bool) + samples := make([]string, 0, sampleLimit) + total := 0 + var lastErr error + successfulQuery := false + + for _, query := range queries { + cmd := exec.Command("journalctl", query...) + output, err := cmd.CombinedOutput() + if err != nil { + lastErr = err + continue + } + successfulQuery = true + total += collectGreeterAppArmorDenials(string(output), seen, &samples, sampleLimit) + } + + if !successfulQuery && lastErr != nil { + return 0, nil, lastErr + } + + return total, samples, nil +} + +func collectGreeterAppArmorDenials(text string, seen map[string]bool, samples *[]string, sampleLimit int) int { + count := 0 + for _, rawLine := range strings.Split(text, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || !isGreeterRelatedAppArmorDenial(line) { + continue + } + if seen[line] { + continue + } + seen[line] = true + count++ + if len(*samples) < sampleLimit { + *samples = append(*samples, line) + } + } + return count +} + +func isGreeterRelatedAppArmorDenial(line string) bool { + lower := strings.ToLower(line) + if !strings.Contains(lower, "apparmor") || !strings.Contains(lower, "denied") { + return false + } + + greeterTokens := []string{ + "dms-greeter", + "/usr/bin/dms-greeter", + "greetd", + "quickshell", + "/usr/bin/qs", + "/usr/bin/quickshell", + "niri", + "hyprland", + "sway", + "mango", + "miracle", + "labwc", + "pipewire", + "wireplumber", + "stream fd", + } + + for _, token := range greeterTokens { + if strings.Contains(lower, token) { + return true + } + } + return false +} + +// appArmorProfileMode returns "complain", "enforce", or "" for a named AppArmor profile. +func appArmorProfileMode(profileName string) string { + data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if !strings.Contains(line, profileName) { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, "(complain)") { + return "complain" + } + if strings.Contains(lower, "(enforce)") { + return "enforce" + } + if strings.Contains(lower, "(kill)") { + return "kill" + } + } + return "" +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter_test.go new file mode 100644 index 0000000..775149f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "errors" + "reflect" + "testing" + + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" +) + +func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) { + origGreeterConfigSyncFn := greeterConfigSyncFn + origSharedAuthSyncFn := sharedAuthSyncFn + t.Cleanup(func() { + greeterConfigSyncFn = origGreeterConfigSyncFn + sharedAuthSyncFn = origSharedAuthSyncFn + }) + + var calls []string + greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + if dmsPath != "/tmp/dms" { + t.Fatalf("unexpected dmsPath %q", dmsPath) + } + if compositor != "niri" { + t.Fatalf("unexpected compositor %q", compositor) + } + if sudoPassword != "" { + t.Fatalf("expected empty sudoPassword, got %q", sudoPassword) + } + calls = append(calls, "configs") + return nil + } + + var gotOptions sharedpam.SyncAuthOptions + sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error { + if sudoPassword != "" { + t.Fatalf("expected empty sudoPassword, got %q", sudoPassword) + } + gotOptions = options + calls = append(calls, "auth") + return nil + } + + err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{ + ForceGreeterAuth: true, + }, func() { + calls = append(calls, "before-auth") + }) + if err != nil { + t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err) + } + + wantCalls := []string{"configs", "before-auth", "auth"} + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("call order = %v, want %v", calls, wantCalls) + } + if !gotOptions.ForceGreeterAuth { + t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions) + } +} + +func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) { + origGreeterConfigSyncFn := greeterConfigSyncFn + origSharedAuthSyncFn := sharedAuthSyncFn + t.Cleanup(func() { + greeterConfigSyncFn = origGreeterConfigSyncFn + sharedAuthSyncFn = origSharedAuthSyncFn + }) + + greeterConfigSyncFn = func(string, string, func(string), string) error { + return errors.New("config sync failed") + } + + authCalled := false + sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error { + authCalled = true + return nil + } + + err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil) + if err == nil || err.Error() != "config sync failed" { + t.Fatalf("expected config sync error, got %v", err) + } + if authCalled { + t.Fatal("expected auth sync not to run after config sync failure") + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_keybinds.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_keybinds.go new file mode 100644 index 0000000..cdb15ac --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_keybinds.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var keybindsCmd = &cobra.Command{ + Use: "keybinds", + Aliases: []string{"cheatsheet", "chsht"}, + Short: "Manage keybinds and cheatsheets", + Long: "Display and manage keybinds and cheatsheets for various applications", +} + +var keybindsListCmd = &cobra.Command{ + Use: "list", + Short: "List available providers", + Long: "List all available keybind/cheatsheet providers", + Run: runKeybindsList, +} + +var keybindsShowCmd = &cobra.Command{ + Use: "show <provider>", + Short: "Show keybinds for a provider", + Long: "Display keybinds/cheatsheet for the specified provider", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + registry := keybinds.GetDefaultRegistry() + return registry.List(), cobra.ShellCompDirectiveNoFileComp + }, + Run: runKeybindsShow, +} + +var keybindsSetCmd = &cobra.Command{ + Use: "set <provider> <key> <action>", + Short: "Set a keybind override", + Long: "Create or update a keybind override for the specified provider", + Args: cobra.ExactArgs(3), + Run: runKeybindsSet, +} + +var keybindsRemoveCmd = &cobra.Command{ + Use: "remove <provider> <key>", + Short: "Remove a keybind override", + Long: "Remove a keybind override from the specified provider", + Args: cobra.ExactArgs(2), + Run: runKeybindsRemove, +} + +func init() { + keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON") + keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") + keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay") + keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked") + keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds") + keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat") + keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)") + keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)") + keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)") + + keybindsCmd.AddCommand(keybindsListCmd) + keybindsCmd.AddCommand(keybindsShowCmd) + keybindsCmd.AddCommand(keybindsSetCmd) + keybindsCmd.AddCommand(keybindsRemoveCmd) + + keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) { + return providers.NewJSONFileProvider(filePath) + }) + + initializeProviders() +} + +func initializeProviders() { + registry := keybinds.GetDefaultRegistry() + + hyprlandProvider := providers.NewHyprlandProvider("") + if err := registry.Register(hyprlandProvider); err != nil { + log.Warnf("Failed to register Hyprland provider: %v", err) + } + + mangowcProvider := providers.NewMangoWCProvider("") + if err := registry.Register(mangowcProvider); err != nil { + log.Warnf("Failed to register MangoWC provider: %v", err) + } + + configDir, _ := os.UserConfigDir() + + if configDir != "" { + scrollProvider := providers.NewSwayProvider(filepath.Join(configDir, "scroll")) + if err := registry.Register(scrollProvider); err != nil { + log.Warnf("Failed to register Scroll provider: %v", err) + } + } + + miracleProvider := providers.NewMiracleProvider("") + if err := registry.Register(miracleProvider); err != nil { + log.Warnf("Failed to register Miracle WM provider: %v", err) + } + + if configDir != "" { + swayProvider := providers.NewSwayProvider(filepath.Join(configDir, "sway")) + if err := registry.Register(swayProvider); err != nil { + log.Warnf("Failed to register Sway provider: %v", err) + } + } + + niriProvider := providers.NewNiriProvider("") + if err := registry.Register(niriProvider); err != nil { + log.Warnf("Failed to register Niri provider: %v", err) + } + + config := keybinds.DefaultDiscoveryConfig() + if err := keybinds.AutoDiscoverProviders(registry, config); err != nil { + log.Warnf("Failed to auto-discover providers: %v", err) + } +} + +func runKeybindsList(cmd *cobra.Command, _ []string) { + providerList := keybinds.GetDefaultRegistry().List() + asJSON, _ := cmd.Flags().GetBool("json") + + if asJSON { + output, _ := json.Marshal(providerList) + fmt.Fprintln(os.Stdout, string(output)) + return + } + + if len(providerList) == 0 { + fmt.Fprintln(os.Stdout, "No providers available") + return + } + + fmt.Fprintln(os.Stdout, "Available providers:") + for _, name := range providerList { + fmt.Fprintf(os.Stdout, " - %s\n", name) + } +} + +func makeProviderWithPath(name, path string) keybinds.Provider { + switch name { + case "hyprland": + return providers.NewHyprlandProvider(path) + case "mangowc": + return providers.NewMangoWCProvider(path) + case "sway": + return providers.NewSwayProvider(path) + case "scroll": + return providers.NewSwayProvider(path) + case "miracle": + return providers.NewMiracleProvider(path) + case "niri": + return providers.NewNiriProvider(path) + default: + return nil + } +} + +func printCheatSheet(provider keybinds.Provider) { + sheet, err := provider.GetCheatSheet() + if err != nil { + log.Fatalf("Error getting cheatsheet: %v", err) + } + output, err := json.MarshalIndent(sheet, "", " ") + if err != nil { + log.Fatalf("Error generating JSON: %v", err) + } + fmt.Fprintln(os.Stdout, string(output)) +} + +func runKeybindsShow(cmd *cobra.Command, args []string) { + providerName := args[0] + customPath, _ := cmd.Flags().GetString("path") + + if customPath != "" { + provider := makeProviderWithPath(providerName, customPath) + if provider == nil { + log.Fatalf("Provider %s does not support custom path", providerName) + } + printCheatSheet(provider) + return + } + + provider, err := keybinds.GetDefaultRegistry().Get(providerName) + if err != nil { + log.Fatalf("Error: %v", err) + } + printCheatSheet(provider) +} + +func getWritableProvider(name string) keybinds.WritableProvider { + provider, err := keybinds.GetDefaultRegistry().Get(name) + if err != nil { + log.Fatalf("Error: %v", err) + } + writable, ok := provider.(keybinds.WritableProvider) + if !ok { + log.Fatalf("Provider %s does not support writing keybinds", name) + } + return writable +} + +func runKeybindsSet(cmd *cobra.Command, args []string) { + providerName, key, action := args[0], args[1], args[2] + writable := getWritableProvider(providerName) + + if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key { + _ = writable.RemoveBind(replaceKey) + } + + options := make(map[string]any) + if v, _ := cmd.Flags().GetBool("allow-when-locked"); v { + options["allow-when-locked"] = true + } + if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 { + options["cooldown-ms"] = v + } + if v, _ := cmd.Flags().GetBool("no-repeat"); v { + options["repeat"] = false + } + if v, _ := cmd.Flags().GetBool("no-inhibiting"); v { + options["allow-inhibiting"] = false + } + if v, _ := cmd.Flags().GetString("flags"); v != "" { + options["flags"] = v + } + + desc, _ := cmd.Flags().GetString("desc") + if err := writable.SetBind(key, action, desc, options); err != nil { + log.Fatalf("Error setting keybind: %v", err) + } + + output, _ := json.MarshalIndent(map[string]any{ + "success": true, + "key": key, + "action": action, + "path": writable.GetOverridePath(), + }, "", " ") + fmt.Fprintln(os.Stdout, string(output)) +} + +func runKeybindsRemove(_ *cobra.Command, args []string) { + providerName, key := args[0], args[1] + writable := getWritableProvider(providerName) + + if err := writable.RemoveBind(key); err != nil { + log.Fatalf("Error removing keybind: %v", err) + } + + output, _ := json.MarshalIndent(map[string]any{ + "success": true, + "key": key, + "removed": true, + }, "", " ") + fmt.Fprintln(os.Stdout, string(output)) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_matugen.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_matugen.go new file mode 100644 index 0000000..566f360 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_matugen.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/spf13/cobra" +) + +var matugenCmd = &cobra.Command{ + Use: "matugen", + Short: "Generate Material Design themes", + Long: "Generate Material Design themes using matugen with dank16 color integration", +} + +var matugenGenerateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate theme synchronously", + Run: runMatugenGenerate, +} + +var matugenQueueCmd = &cobra.Command{ + Use: "queue", + Short: "Queue theme generation (uses socket if available)", + Run: runMatugenQueue, +} + +var matugenCheckCmd = &cobra.Command{ + Use: "check", + Short: "Check which template apps are detected", + Run: runMatugenCheck, +} + +func init() { + matugenCmd.AddCommand(matugenGenerateCmd) + matugenCmd.AddCommand(matugenQueueCmd) + matugenCmd.AddCommand(matugenCheckCmd) + + for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} { + cmd.Flags().String("state-dir", "", "State directory for cache files") + cmd.Flags().String("shell-dir", "", "DMS shell installation directory") + cmd.Flags().String("config-dir", "", "User config directory") + cmd.Flags().String("kind", "image", "Source type: image or hex") + cmd.Flags().String("value", "", "Wallpaper path or hex color") + cmd.Flags().String("mode", "dark", "Color mode: dark or light") + cmd.Flags().String("icon-theme", "System Default", "Icon theme name") + cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type") + cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates") + cmd.Flags().String("stock-colors", "", "Stock theme colors JSON") + cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal") + cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant") + cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip") + cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)") + } + + matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") + matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting") +} + +func buildMatugenOptions(cmd *cobra.Command) matugen.Options { + stateDir, _ := cmd.Flags().GetString("state-dir") + shellDir, _ := cmd.Flags().GetString("shell-dir") + configDir, _ := cmd.Flags().GetString("config-dir") + kind, _ := cmd.Flags().GetString("kind") + value, _ := cmd.Flags().GetString("value") + mode, _ := cmd.Flags().GetString("mode") + iconTheme, _ := cmd.Flags().GetString("icon-theme") + matugenType, _ := cmd.Flags().GetString("matugen-type") + runUserTemplates, _ := cmd.Flags().GetBool("run-user-templates") + stockColors, _ := cmd.Flags().GetString("stock-colors") + syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal") + terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark") + skipTemplates, _ := cmd.Flags().GetString("skip-templates") + contrast, _ := cmd.Flags().GetFloat64("contrast") + + return matugen.Options{ + StateDir: stateDir, + ShellDir: shellDir, + ConfigDir: configDir, + Kind: kind, + Value: value, + Mode: matugen.ColorMode(mode), + IconTheme: iconTheme, + MatugenType: matugenType, + Contrast: contrast, + RunUserTemplates: runUserTemplates, + StockColors: stockColors, + SyncModeWithPortal: syncModeWithPortal, + TerminalsAlwaysDark: terminalsAlwaysDark, + SkipTemplates: skipTemplates, + } +} + +func runMatugenGenerate(cmd *cobra.Command, args []string) { + opts := buildMatugenOptions(cmd) + err := matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + os.Exit(2) + case err != nil: + log.Fatalf("Theme generation failed: %v", err) + } +} + +func runMatugenQueue(cmd *cobra.Command, args []string) { + opts := buildMatugenOptions(cmd) + wait, _ := cmd.Flags().GetBool("wait") + timeout, _ := cmd.Flags().GetDuration("timeout") + + request := models.Request{ + ID: 1, + Method: "matugen.queue", + Params: map[string]any{ + "stateDir": opts.StateDir, + "shellDir": opts.ShellDir, + "configDir": opts.ConfigDir, + "kind": opts.Kind, + "value": opts.Value, + "mode": opts.Mode, + "iconTheme": opts.IconTheme, + "matugenType": opts.MatugenType, + "runUserTemplates": opts.RunUserTemplates, + "stockColors": opts.StockColors, + "syncModeWithPortal": opts.SyncModeWithPortal, + "terminalsAlwaysDark": opts.TerminalsAlwaysDark, + "skipTemplates": opts.SkipTemplates, + "contrast": opts.Contrast, + "wait": wait, + }, + } + + if !wait { + if err := sendServerRequestFireAndForget(request); err != nil { + log.Info("Server unavailable, running synchronously") + err := matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + os.Exit(2) + case err != nil: + log.Fatalf("Theme generation failed: %v", err) + } + return + } + fmt.Println("Theme generation queued") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + resultCh := make(chan error, 1) + go func() { + resp, ok := tryServerRequest(request) + if !ok { + log.Info("Server unavailable, running synchronously") + err := matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + resultCh <- matugen.ErrNoChanges + case err != nil: + resultCh <- err + default: + resultCh <- nil + } + return + } + if resp.Error != "" { + resultCh <- fmt.Errorf("server error: %s", resp.Error) + return + } + resultCh <- nil + }() + + select { + case err := <-resultCh: + switch { + case errors.Is(err, matugen.ErrNoChanges): + os.Exit(2) + case err != nil: + log.Fatalf("Theme generation failed: %v", err) + } + fmt.Println("Theme generation completed") + case <-ctx.Done(): + log.Fatalf("Timeout waiting for theme generation") + } +} + +func runMatugenCheck(cmd *cobra.Command, args []string) { + checks := matugen.CheckTemplates(nil) + data, err := json.Marshal(checks) + if err != nil { + log.Fatalf("Failed to marshal check results: %v", err) + } + fmt.Println(string(data)) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_notify.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_notify.go new file mode 100644 index 0000000..e04b60f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_notify.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/notify" + "github.com/spf13/cobra" +) + +var ( + notifyAppName string + notifyIcon string + notifyFile string + notifyTimeout int +) + +var notifyCmd = &cobra.Command{ + Use: "notify <summary> [body]", + Short: "Send a desktop notification", + Long: `Send a desktop notification with optional actions. + +If --file is provided, the notification will have "Open" and "Open Folder" actions. + +Examples: + dms notify "Hello" "World" + dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone + dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`, + Args: cobra.MinimumNArgs(1), + Run: runNotify, +} + +var genericNotifyActionCmd = &cobra.Command{ + Use: "notify-action-generic", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + notify.RunActionListener(args) + }, +} + +func init() { + notifyCmd.Flags().StringVar(¬ifyAppName, "app", "DMS", "Application name") + notifyCmd.Flags().StringVar(¬ifyIcon, "icon", "", "Icon name or path") + notifyCmd.Flags().StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)") + notifyCmd.Flags().IntVar(¬ifyTimeout, "timeout", 5000, "Timeout in milliseconds") +} + +func runNotify(cmd *cobra.Command, args []string) { + summary := args[0] + body := "" + if len(args) > 1 { + body = args[1] + } + + n := notify.Notification{ + AppName: notifyAppName, + Icon: notifyIcon, + Summary: summary, + Body: body, + FilePath: notifyFile, + Timeout: int32(notifyTimeout), + } + + if err := notify.Send(n); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_open.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_open.go new file mode 100644 index 0000000..8cc16cf --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_open.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "mime" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/spf13/cobra" +) + +var ( + openMimeType string + openCategories []string + openRequestType string +) + +var openCmd = &cobra.Command{ + Use: "open [target]", + Short: "Open a file, URL, or resource with an application picker", + Long: `Open a target (URL, file, or other resource) using the DMS application picker. +By default, this opens URLs with the browser picker. You can customize the behavior +with flags to handle different MIME types or application categories. + +Examples: + dms open https://example.com # Open URL with browser picker + dms open file.pdf --mime application/pdf # Open PDF with compatible apps + dms open document.odt --category Office # Open with office applications + dms open --mime image/png image.png # Open image with image viewers`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runOpen(args[0]) + }, +} + +func init() { + rootCmd.AddCommand(openCmd) + openCmd.Flags().StringVar(&openMimeType, "mime", "", "MIME type for filtering applications") + openCmd.Flags().StringSliceVar(&openCategories, "category", []string{}, "Application categories to filter (e.g., WebBrowser, Office, Graphics)") + openCmd.Flags().StringVar(&openRequestType, "type", "url", "Request type (url, file, or custom)") + _ = openCmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"url", "file", "custom"}, cobra.ShellCompDirectiveNoFileComp + }) +} + +// mimeTypeToCategories maps MIME types to desktop file categories +func mimeTypeToCategories(mimeType string) []string { + // Split MIME type to get the main type + parts := strings.Split(mimeType, "/") + if len(parts) < 1 { + return nil + } + + mainType := parts[0] + + switch mainType { + case "image": + return []string{"Graphics", "Viewer"} + case "video": + return []string{"Video", "AudioVideo"} + case "audio": + return []string{"Audio", "AudioVideo"} + case "text": + if strings.Contains(mimeType, "html") { + return []string{"WebBrowser"} + } + return []string{"TextEditor", "Office"} + case "application": + if strings.Contains(mimeType, "pdf") { + return []string{"Office", "Viewer"} + } + if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") || + strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") || + strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") || + strings.Contains(mimeType, "opendocument") { + return []string{"Office"} + } + if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || + strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") { + return []string{"Archiving", "Utility"} + } + return []string{"Office", "Viewer"} + } + + return nil +} + +func runOpen(target string) { + // Parse file:// URIs to extract the actual file path + actualTarget := target + detectedMimeType := openMimeType + detectedCategories := openCategories + detectedRequestType := openRequestType + + log.Infof("Processing target: %s", target) + + if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" { + // Extract file path from file:// URI and convert to absolute path + actualTarget = parsedURL.Path + if absPath, err := filepath.Abs(actualTarget); err == nil { + actualTarget = absPath + } + + if detectedRequestType == "url" || detectedRequestType == "" { + detectedRequestType = "file" + } + + log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget) + + // Auto-detect MIME type if not provided + if detectedMimeType == "" { + ext := filepath.Ext(actualTarget) + if ext != "" { + detectedMimeType = mime.TypeByExtension(ext) + log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType) + } + } + + // Auto-detect categories based on MIME type if not provided + if len(detectedCategories) == 0 && detectedMimeType != "" { + detectedCategories = mimeTypeToCategories(detectedMimeType) + log.Infof("Detected categories from MIME type: %v", detectedCategories) + } + } else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + // Handle HTTP(S) URLs + if detectedRequestType == "" { + detectedRequestType = "url" + } + log.Infof("Detected HTTP(S) URL") + } else if strings.HasPrefix(target, "dms://") { + // Handle DMS internal URLs (theme/plugin install, etc.) + if detectedRequestType == "" { + detectedRequestType = "url" + } + log.Infof("Detected DMS internal URL") + } else if _, err := os.Stat(target); err == nil { + // Handle local file paths directly (not file:// URIs) + // Convert to absolute path + if absPath, err := filepath.Abs(target); err == nil { + actualTarget = absPath + } + + if detectedRequestType == "url" || detectedRequestType == "" { + detectedRequestType = "file" + } + + log.Infof("Detected local file path, converted to absolute: %s", actualTarget) + + // Auto-detect MIME type if not provided + if detectedMimeType == "" { + ext := filepath.Ext(actualTarget) + if ext != "" { + detectedMimeType = mime.TypeByExtension(ext) + log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType) + } + } + + // Auto-detect categories based on MIME type if not provided + if len(detectedCategories) == 0 && detectedMimeType != "" { + detectedCategories = mimeTypeToCategories(detectedMimeType) + log.Infof("Detected categories from MIME type: %v", detectedCategories) + } + } + + params := map[string]any{ + "target": actualTarget, + } + + if detectedMimeType != "" { + params["mimeType"] = detectedMimeType + } + + if len(detectedCategories) > 0 { + params["categories"] = detectedCategories + } + + if detectedRequestType != "" { + params["requestType"] = detectedRequestType + } + + method := "apppicker.open" + if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) { + method = "browser.open" + params["url"] = target + } + + req := models.Request{ + ID: 1, + Method: method, + Params: params, + } + + log.Infof("Sending request - Method: %s, Params: %+v", method, params) + + if err := sendServerRequestFireAndForget(req); err != nil { + fmt.Println("DMS is not running. Please start DMS first.") + os.Exit(1) + } + + log.Infof("Request sent successfully") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_randr.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_randr.go new file mode 100644 index 0000000..fa0c990 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_randr.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var randrCmd = &cobra.Command{ + Use: "randr", + Short: "Query output display information", + Long: "Query Wayland compositor for output names, scales, resolutions and refresh rates via zwlr-output-management", + Run: runRandr, +} + +func init() { + randrCmd.Flags().Bool("json", false, "Output in JSON format") +} + +type randrJSON struct { + Outputs []randrOutput `json:"outputs"` +} + +func runRandr(cmd *cobra.Command, args []string) { + outputs, err := queryRandr() + if err != nil { + log.Fatalf("%v", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + + if jsonFlag { + data, err := json.Marshal(randrJSON{Outputs: outputs}) + if err != nil { + log.Fatalf("failed to marshal JSON: %v", err) + } + fmt.Println(string(data)) + return + } + + for i, out := range outputs { + if i > 0 { + fmt.Println() + } + status := "enabled" + if !out.Enabled { + status = "disabled" + } + fmt.Printf("%s (%s)\n", out.Name, status) + fmt.Printf(" Scale: %.4g\n", out.Scale) + fmt.Printf(" Resolution: %dx%d\n", out.Width, out.Height) + if out.Refresh > 0 { + fmt.Printf(" Refresh: %.2f Hz\n", float64(out.Refresh)/1000.0) + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_root.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_root.go new file mode 100644 index 0000000..b263ae6 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_root.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var customConfigPath string +var configPath string + +var rootCmd = &cobra.Command{ + Use: "dms", + Short: "dms CLI", + Long: "dms is the DankMaterialShell management CLI and backend server.", +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory") +} + +func findConfig(cmd *cobra.Command, args []string) error { + if customConfigPath != "" { + log.Debug("Custom config path provided via -c flag: %s", customConfigPath) + shellPath := filepath.Join(customConfigPath, "shell.qml") + + info, statErr := os.Stat(shellPath) + + if statErr == nil && !info.IsDir() { + configPath = customConfigPath + log.Debug("Using config from: %s", configPath) + return nil + } + + if statErr != nil { + return fmt.Errorf("custom config path error: %w", statErr) + } + + return fmt.Errorf("path is a directory, not a file: %s", shellPath) + } + + configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") + if data, readErr := os.ReadFile(configStateFile); readErr == nil { + if len(getAllDMSPIDs()) == 0 { + os.Remove(configStateFile) + } else { + statePath := strings.TrimSpace(string(data)) + shellPath := filepath.Join(statePath, "shell.qml") + + if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() { + log.Debug("Using config from active session state file: %s", statePath) + configPath = statePath + log.Debug("Using config from: %s", configPath) + return nil + } + os.Remove(configStateFile) + } + } + + log.Debug("No custom path or active session, searching default XDG locations...") + var err error + configPath, err = config.LocateDMSConfig() + if err != nil { + return err + } + + log.Debug("Using config from: %s", configPath) + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_screenshot.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_screenshot.go new file mode 100644 index 0000000..2875019 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_screenshot.go @@ -0,0 +1,422 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot" + "github.com/spf13/cobra" +) + +var ( + ssOutputName string + ssCursor string + ssFormat string + ssQuality int + ssOutputDir string + ssFilename string + ssNoClipboard bool + ssNoFile bool + ssNoNotify bool + ssNoConfirm bool + ssReset bool + ssStdout bool +) + +var screenshotCmd = &cobra.Command{ + Use: "screenshot", + Short: "Capture screenshots", + Long: `Capture screenshots from Wayland displays. + +Modes: + region - Select a region interactively (default) + full - Capture the focused output + all - Capture all outputs combined + output - Capture a specific output by name + window - Capture the focused window (Hyprland/DWL) + last - Capture the last selected region + +Output format (--format): + png - PNG format (default) + jpg/jpeg - JPEG format + ppm - PPM format + +Examples: + dms screenshot # Region select, save file + clipboard + dms screenshot full # Full screen of focused output + dms screenshot all # All screens combined + dms screenshot output -o DP-1 # Specific output + dms screenshot window # Focused window (Hyprland) + dms screenshot last # Last region (pre-selected) + dms screenshot --reset # Reset last region pre-selection + dms screenshot --no-clipboard # Save file only + dms screenshot --no-file # Clipboard only + dms screenshot --no-confirm # Region capture on mouse release + dms screenshot --cursor=on # Include cursor + dms screenshot -f jpg -q 85 # JPEG with quality 85`, +} + +var ssRegionCmd = &cobra.Command{ + Use: "region", + Short: "Select a region interactively", + Run: runScreenshotRegion, +} + +var ssFullCmd = &cobra.Command{ + Use: "full", + Short: "Capture the focused output", + Run: runScreenshotFull, +} + +var ssAllCmd = &cobra.Command{ + Use: "all", + Short: "Capture all outputs combined", + Run: runScreenshotAll, +} + +var ssOutputCmd = &cobra.Command{ + Use: "output", + Short: "Capture a specific output", + Run: runScreenshotOutput, +} + +var ssLastCmd = &cobra.Command{ + Use: "last", + Short: "Capture the last selected region", + Long: `Capture the previously selected region without interactive selection. +If no previous region exists, falls back to interactive selection.`, + Run: runScreenshotLast, +} + +var ssWindowCmd = &cobra.Command{ + Use: "window", + Short: "Capture the focused window", + Long: `Capture the currently focused window. Supported on Hyprland and DWL.`, + Run: runScreenshotWindow, +} + +var ssListCmd = &cobra.Command{ + Use: "list", + Short: "List available outputs", + Run: runScreenshotList, +} + +var notifyActionCmd = &cobra.Command{ + Use: "notify-action", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + screenshot.RunNotifyActionListener(args) + }, +} + +func init() { + screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode") + screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)") + screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)") + screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)") + screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory") + screenshotCmd.PersistentFlags().StringVar(&ssFilename, "filename", "", "Output filename (auto-generated if empty)") + screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard") + screenshotCmd.PersistentFlags().BoolVar(&ssNoFile, "no-file", false, "Don't save to file") + screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification") + screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation") + screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing") + screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)") + + screenshotCmd.AddCommand(ssRegionCmd) + screenshotCmd.AddCommand(ssFullCmd) + screenshotCmd.AddCommand(ssAllCmd) + screenshotCmd.AddCommand(ssOutputCmd) + screenshotCmd.AddCommand(ssLastCmd) + screenshotCmd.AddCommand(ssWindowCmd) + screenshotCmd.AddCommand(ssListCmd) + + screenshotCmd.Run = runScreenshotRegion +} + +func getScreenshotConfig(mode screenshot.Mode) screenshot.Config { + config := screenshot.DefaultConfig() + config.Mode = mode + config.OutputName = ssOutputName + if strings.EqualFold(ssCursor, "on") { + config.Cursor = screenshot.CursorOn + } + config.Clipboard = !ssNoClipboard + config.SaveFile = !ssNoFile + config.Notify = !ssNoNotify + config.NoConfirm = ssNoConfirm + config.Reset = ssReset + config.Stdout = ssStdout + + if ssOutputDir != "" { + config.OutputDir = ssOutputDir + } + if ssFilename != "" { + config.Filename = ssFilename + } + + switch strings.ToLower(ssFormat) { + case "jpg", "jpeg": + config.Format = screenshot.FormatJPEG + case "ppm": + config.Format = screenshot.FormatPPM + default: + config.Format = screenshot.FormatPNG + } + + if ssQuality < 1 { + ssQuality = 1 + } + if ssQuality > 100 { + ssQuality = 100 + } + config.Quality = ssQuality + + return config +} + +func runScreenshot(config screenshot.Config) { + sc := screenshot.New(config) + result, err := sc.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if result == nil { + os.Exit(0) + } + + defer result.Buffer.Close() + + if result.YInverted { + result.Buffer.FlipVertical() + } + + if config.Stdout { + if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil { + fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err) + os.Exit(1) + } + return + } + + var filePath string + + if config.SaveFile { + outputDir := config.OutputDir + if outputDir == "" { + outputDir = screenshot.GetOutputDir() + } + + filename := config.Filename + if filename == "" { + filename = screenshot.GenerateFilename(config.Format) + } + + filePath = filepath.Join(outputDir, filename) + if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil { + fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err) + os.Exit(1) + } + fmt.Println(filePath) + } + + if config.Clipboard { + if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil { + fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err) + os.Exit(1) + } + if !config.SaveFile { + fmt.Println("Copied to clipboard") + } + } + + if config.Notify { + thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format) + screenshot.SendNotification(screenshot.NotifyResult{ + FilePath: filePath, + Clipboard: config.Clipboard, + ImageData: thumbData, + Width: thumbW, + Height: thumbH, + }) + } +} + +func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error { + var mimeType string + var data bytes.Buffer + + img := screenshot.BufferToImageWithFormat(buf, pixelFormat) + + switch format { + case screenshot.FormatJPEG: + mimeType = "image/jpeg" + if err := screenshot.EncodeJPEG(&data, img, quality); err != nil { + return err + } + default: + mimeType = "image/png" + if err := screenshot.EncodePNG(&data, img); err != nil { + return err + } + } + + return clipboard.Copy(data.Bytes(), mimeType) +} + +func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error { + img := screenshot.BufferToImageWithFormat(buf, pixelFormat) + + switch format { + case screenshot.FormatJPEG: + return screenshot.EncodeJPEG(os.Stdout, img, quality) + default: + return screenshot.EncodePNG(os.Stdout, img) + } +} + +func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) { + srcW, srcH := buf.Width, buf.Height + scale := 1.0 + if srcW > maxSize || srcH > maxSize { + if srcW > srcH { + scale = float64(maxSize) / float64(srcW) + } else { + scale = float64(maxSize) / float64(srcH) + } + } + + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) + if dstW < 1 { + dstW = 1 + } + if dstH < 1 { + dstH = 1 + } + + data := buf.Data() + rgb := make([]byte, dstW*dstH*3) + + var swapRB bool + switch pixelFormat { + case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888): + swapRB = false + default: + swapRB = true + } + + for y := 0; y < dstH; y++ { + srcY := int(float64(y) / scale) + if srcY >= srcH { + srcY = srcH - 1 + } + for x := 0; x < dstW; x++ { + srcX := int(float64(x) / scale) + if srcX >= srcW { + srcX = srcW - 1 + } + si := srcY*buf.Stride + srcX*4 + di := (y*dstW + x) * 3 + if si+3 >= len(data) { + continue + } + if swapRB { + rgb[di+0] = data[si+2] + rgb[di+1] = data[si+1] + rgb[di+2] = data[si+0] + } else { + rgb[di+0] = data[si+0] + rgb[di+1] = data[si+1] + rgb[di+2] = data[si+2] + } + } + } + return rgb, dstW, dstH +} + +func runScreenshotRegion(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeRegion) + runScreenshot(config) +} + +func runScreenshotFull(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeFullScreen) + runScreenshot(config) +} + +func runScreenshotAll(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeAllScreens) + runScreenshot(config) +} + +func runScreenshotOutput(cmd *cobra.Command, args []string) { + if ssOutputName == "" && len(args) > 0 { + ssOutputName = args[0] + } + if ssOutputName == "" { + fmt.Fprintln(os.Stderr, "Error: output name required (use -o or provide as argument)") + os.Exit(1) + } + config := getScreenshotConfig(screenshot.ModeOutput) + runScreenshot(config) +} + +func runScreenshotLast(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeLastRegion) + runScreenshot(config) +} + +func runScreenshotWindow(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeWindow) + runScreenshot(config) +} + +func runScreenshotList(cmd *cobra.Command, args []string) { + outputs, err := screenshot.ListOutputs() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + for _, o := range outputs { + scaleStr := fmt.Sprintf("%.2f", o.FractionalScale) + if o.FractionalScale == float64(int(o.FractionalScale)) { + scaleStr = fmt.Sprintf("%d", int(o.FractionalScale)) + } + + transformStr := transformName(o.Transform) + + fmt.Printf("%s: %dx%d+%d+%d scale=%s transform=%s\n", + o.Name, o.Width, o.Height, o.X, o.Y, scaleStr, transformStr) + } +} + +func transformName(t int32) string { + switch t { + case 0: + return "normal" + case 1: + return "90" + case 2: + return "180" + case 3: + return "270" + case 4: + return "flipped" + case 5: + return "flipped-90" + case 6: + return "flipped-180" + case 7: + return "flipped-270" + default: + return fmt.Sprintf("%d", t) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_setup.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_setup.go new file mode 100644 index 0000000..0f4a58d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_setup.go @@ -0,0 +1,436 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/spf13/cobra" +) + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Deploy DMS configurations", + Long: "Deploy compositor and terminal configurations with interactive prompts", + PersistentPreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + if err := runSetup(); err != nil { + log.Fatalf("Error during setup: %v", err) + } + }, +} + +var setupBindsCmd = &cobra.Command{ + Use: "binds", + Short: "Deploy default keybinds config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("binds"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupLayoutCmd = &cobra.Command{ + Use: "layout", + Short: "Deploy default layout config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("layout"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupColorsCmd = &cobra.Command{ + Use: "colors", + Short: "Deploy default colors config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("colors"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupAlttabCmd = &cobra.Command{ + Use: "alttab", + Short: "Deploy default alt-tab config (niri only)", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("alttab"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupOutputsCmd = &cobra.Command{ + Use: "outputs", + Short: "Deploy default outputs config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("outputs"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupCursorCmd = &cobra.Command{ + Use: "cursor", + Short: "Deploy default cursor config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("cursor"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupWindowrulesCmd = &cobra.Command{ + Use: "windowrules", + Short: "Deploy default window rules config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("windowrules"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +type dmsConfigSpec struct { + niriFile string + hyprFile string + niriContent func(terminal string) string + hyprContent func(terminal string) string +} + +var dmsConfigSpecs = map[string]dmsConfigSpec{ + "binds": { + niriFile: "binds.kdl", + hyprFile: "binds.conf", + niriContent: func(t string) string { + return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t) + }, + hyprContent: func(t string) string { + return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t) + }, + }, + "layout": { + niriFile: "layout.kdl", + hyprFile: "layout.conf", + niriContent: func(_ string) string { return config.NiriLayoutConfig }, + hyprContent: func(_ string) string { return config.HyprLayoutConfig }, + }, + "colors": { + niriFile: "colors.kdl", + hyprFile: "colors.conf", + niriContent: func(_ string) string { return config.NiriColorsConfig }, + hyprContent: func(_ string) string { return config.HyprColorsConfig }, + }, + "alttab": { + niriFile: "alttab.kdl", + niriContent: func(_ string) string { return config.NiriAlttabConfig }, + }, + "outputs": { + niriFile: "outputs.kdl", + hyprFile: "outputs.conf", + niriContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return "" }, + }, + "cursor": { + niriFile: "cursor.kdl", + hyprFile: "cursor.conf", + niriContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return "" }, + }, + "windowrules": { + niriFile: "windowrules.kdl", + hyprFile: "windowrules.conf", + niriContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return "" }, + }, +} + +func detectTerminal() (string, error) { + terminals := []string{"ghostty", "foot", "kitty", "alacritty"} + var found []string + for _, t := range terminals { + if utils.CommandExists(t) { + found = append(found, t) + } + } + + switch len(found) { + case 0: + return "ghostty", nil + case 1: + return found[0], nil + } + + fmt.Println("Multiple terminals detected:") + for i, t := range found { + fmt.Printf("%d) %s\n", i+1, t) + } + fmt.Printf("\nChoice (1-%d): ", len(found)) + + var response string + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + choice := 0 + fmt.Sscanf(response, "%d", &choice) + if choice < 1 || choice > len(found) { + return "", fmt.Errorf("invalid choice") + } + return found[choice-1], nil +} + +func detectCompositorForSetup() (string, error) { + compositors := greeter.DetectCompositors() + + switch len(compositors) { + case 0: + return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)") + case 1: + return strings.ToLower(compositors[0]), nil + } + + selected, err := greeter.PromptCompositorChoice(compositors) + if err != nil { + return "", err + } + return strings.ToLower(selected), nil +} + +func runSetupDmsConfig(name string) error { + spec, ok := dmsConfigSpecs[name] + if !ok { + return fmt.Errorf("unknown config: %s", name) + } + + compositor, err := detectCompositorForSetup() + if err != nil { + return err + } + + var filename string + var contentFn func(string) string + switch compositor { + case "niri": + filename = spec.niriFile + contentFn = spec.niriContent + case "hyprland": + filename = spec.hyprFile + contentFn = spec.hyprContent + default: + return fmt.Errorf("unsupported compositor: %s", compositor) + } + + if filename == "" { + return fmt.Errorf("%s is not supported for %s", name, compositor) + } + + var dmsDir string + switch compositor { + case "niri": + dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms") + case "hyprland": + dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms") + } + + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + path := filepath.Join(dmsDir, filename) + if info, err := os.Stat(path); err == nil && info.Size() > 0 { + return fmt.Errorf("%s already exists and is not empty: %s", name, path) + } + + terminal := "ghostty" + if contentFn != nil && name == "binds" { + terminal, err = detectTerminal() + if err != nil { + return err + } + } + + content := contentFn(terminal) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", filename, err) + } + + fmt.Printf("Deployed %s to %s\n", name, path) + return nil +} + +func runSetup() error { + fmt.Println("=== DMS Configuration Setup ===") + + wm, wmSelected := promptCompositor() + terminal, terminalSelected := promptTerminal() + useSystemd := promptSystemd() + + if !wmSelected && !terminalSelected { + fmt.Println("No configurations selected. Exiting.") + return nil + } + + if wmSelected || terminalSelected { + willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected) + if willBackup { + fmt.Println("\n⚠ Existing configurations will be backed up with timestamps.") + } + + fmt.Print("\nProceed with deployment? (y/N): ") + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "y" && response != "yes" { + fmt.Println("Setup cancelled.") + return nil + } + } + + fmt.Println("\nDeploying configurations...") + logChan := make(chan string, 100) + deployer := config.NewConfigDeployer(logChan) + + go func() { + for msg := range logChan { + fmt.Println(" " + msg) + } + }() + + ctx := context.Background() + var results []config.DeploymentResult + var err error + + if wmSelected && terminalSelected { + results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, terminal, useSystemd) + } else if wmSelected { + results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, deps.TerminalGhostty, useSystemd) + if len(results) > 1 { + results = results[:1] + } + } else if terminalSelected { + results, err = deployer.DeployConfigurationsWithSystemd(ctx, deps.WindowManagerNiri, terminal, useSystemd) + if len(results) > 0 && results[0].ConfigType == "Niri" { + results = results[1:] + } + } + + close(logChan) + + if err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + + fmt.Println("\n=== Deployment Complete ===") + for _, result := range results { + if result.Deployed { + fmt.Printf("✓ %s: %s\n", result.ConfigType, result.Path) + if result.BackupPath != "" { + fmt.Printf(" Backup: %s\n", result.BackupPath) + } + } + } + + return nil +} + +func promptCompositor() (deps.WindowManager, bool) { + fmt.Println("Select compositor:") + fmt.Println("1) Niri") + fmt.Println("2) Hyprland") + fmt.Println("3) None") + + var response string + fmt.Print("\nChoice (1-3): ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + switch response { + case "1": + return deps.WindowManagerNiri, true + case "2": + return deps.WindowManagerHyprland, true + default: + return deps.WindowManagerNiri, false + } +} + +func promptTerminal() (deps.Terminal, bool) { + fmt.Println("\nSelect terminal:") + fmt.Println("1) Ghostty") + fmt.Println("2) Kitty") + fmt.Println("3) Alacritty") + fmt.Println("4) None") + + var response string + fmt.Print("\nChoice (1-4): ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + switch response { + case "1": + return deps.TerminalGhostty, true + case "2": + return deps.TerminalKitty, true + case "3": + return deps.TerminalAlacritty, true + default: + return deps.TerminalGhostty, false + } +} + +func promptSystemd() bool { + fmt.Println("\nUse systemd for session management?") + fmt.Println("1) Yes (recommended for most distros)") + fmt.Println("2) No (standalone, no systemd integration)") + + var response string + fmt.Print("\nChoice (1-2): ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + return response != "2" +} + +func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool { + homeDir := os.Getenv("HOME") + willBackup := false + + if wmSelected { + var configPath string + switch wm { + case deps.WindowManagerNiri: + configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl") + case deps.WindowManagerHyprland: + configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf") + } + + if _, err := os.Stat(configPath); err == nil { + willBackup = true + } + } + + if terminalSelected { + var configPath string + switch terminal { + case deps.TerminalGhostty: + configPath = filepath.Join(homeDir, ".config", "ghostty", "config") + case deps.TerminalKitty: + configPath = filepath.Join(homeDir, ".config", "kitty", "kitty.conf") + case deps.TerminalAlacritty: + configPath = filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml") + } + + if _, err := os.Stat(configPath); err == nil { + willBackup = true + } + } + + return willBackup +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_windowrules.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_windowrules.go new file mode 100644 index 0000000..0883811 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/commands_windowrules.go @@ -0,0 +1,338 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" + "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers" + "github.com/spf13/cobra" +) + +var windowrulesCmd = &cobra.Command{ + Use: "windowrules", + Short: "Manage window rules", +} + +var windowrulesListCmd = &cobra.Command{ + Use: "list [compositor]", + Short: "List all window rules", + Long: "List all window rules from compositor config file. Returns JSON with rules and DMS status.", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesList, +} + +var windowrulesAddCmd = &cobra.Command{ + Use: "add <compositor> '<json>'", + Short: "Add a window rule to DMS file", + Long: "Add a new window rule to the DMS-managed rules file.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesAdd, +} + +var windowrulesUpdateCmd = &cobra.Command{ + Use: "update <compositor> <id> '<json>'", + Short: "Update a window rule in DMS file", + Long: "Update an existing window rule in the DMS-managed rules file.", + Args: cobra.ExactArgs(3), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesUpdate, +} + +var windowrulesRemoveCmd = &cobra.Command{ + Use: "remove <compositor> <id>", + Short: "Remove a window rule from DMS file", + Long: "Remove a window rule from the DMS-managed rules file.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesRemove, +} + +var windowrulesReorderCmd = &cobra.Command{ + Use: "reorder <compositor> '<json-array-of-ids>'", + Short: "Reorder window rules in DMS file", + Long: "Reorder window rules by providing a JSON array of rule IDs in the desired order.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesReorder, +} + +func init() { + configCmd.AddCommand(windowrulesCmd) + windowrulesCmd.AddCommand(windowrulesListCmd) + windowrulesCmd.AddCommand(windowrulesAddCmd) + windowrulesCmd.AddCommand(windowrulesUpdateCmd) + windowrulesCmd.AddCommand(windowrulesRemoveCmd) + windowrulesCmd.AddCommand(windowrulesReorderCmd) +} + +type WindowRulesListResult struct { + Rules []windowrules.WindowRule `json:"rules"` + DMSStatus *windowrules.DMSRulesStatus `json:"dmsStatus,omitempty"` +} + +type WindowRuleWriteResult struct { + Success bool `json:"success"` + ID string `json:"id,omitempty"` + Path string `json:"path,omitempty"` + Error string `json:"error,omitempty"` +} + +func getCompositor(args []string) string { + if len(args) > 0 { + return strings.ToLower(args[0]) + } + if os.Getenv("NIRI_SOCKET") != "" { + return "niri" + } + // if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { + // return "hyprland" + // } + return "" +} + +func writeRuleError(errMsg string) { + result := WindowRuleWriteResult{Success: false, Error: errMsg} + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) + os.Exit(1) +} + +func writeRuleSuccess(id, path string) { + result := WindowRuleWriteResult{Success: true, ID: id, Path: path} + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) +} + +func runWindowrulesList(cmd *cobra.Command, args []string) { + compositor := getCompositor(args) + if compositor == "" { + log.Fatalf("Could not detect compositor. Please specify: hyprland or niri") + } + + var result WindowRulesListResult + + switch compositor { + case "niri": + configDir, err := utils.ExpandPath("$HOME/.config/niri") + if err != nil { + log.Fatalf("Failed to expand niri config path: %v", err) + } + + parseResult, err := providers.ParseNiriWindowRules(configDir) + if err != nil { + log.Fatalf("Failed to parse niri window rules: %v", err) + } + + allRules := providers.ConvertNiriRulesToWindowRules(parseResult.Rules) + + provider := providers.NewNiriWritableProvider(configDir) + dmsRulesPath := provider.GetOverridePath() + dmsRules, _ := provider.LoadDMSRules() + + dmsRuleMap := make(map[int]windowrules.WindowRule) + for i, dr := range dmsRules { + dmsRuleMap[i] = dr + } + + dmsIdx := 0 + for i, r := range allRules { + if r.Source == dmsRulesPath { + if dmr, ok := dmsRuleMap[dmsIdx]; ok { + allRules[i].ID = dmr.ID + allRules[i].Name = dmr.Name + } + dmsIdx++ + } + } + + result.Rules = allRules + result.DMSStatus = parseResult.DMSStatus + + case "hyprland": + log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland + configDir, err := utils.ExpandPath("$HOME/.config/hypr") + if err != nil { + log.Fatalf("Failed to expand hyprland config path: %v", err) + } + + parseResult, err := providers.ParseHyprlandWindowRules(configDir) + if err != nil { + log.Fatalf("Failed to parse hyprland window rules: %v", err) + } + + allRules := providers.ConvertHyprlandRulesToWindowRules(parseResult.Rules) + + provider := providers.NewHyprlandWritableProvider(configDir) + dmsRulesPath := provider.GetOverridePath() + dmsRules, _ := provider.LoadDMSRules() + + dmsRuleMap := make(map[int]windowrules.WindowRule) + for i, dr := range dmsRules { + dmsRuleMap[i] = dr + } + + dmsIdx := 0 + for i, r := range allRules { + if r.Source == dmsRulesPath { + if dmr, ok := dmsRuleMap[dmsIdx]; ok { + allRules[i].ID = dmr.ID + allRules[i].Name = dmr.Name + } + dmsIdx++ + } + } + + result.Rules = allRules + result.DMSStatus = parseResult.DMSStatus + + default: + log.Fatalf("Unknown compositor: %s", compositor) + } + + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) +} + +func runWindowrulesAdd(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + ruleJSON := args[1] + + var rule windowrules.WindowRule + if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil { + writeRuleError(fmt.Sprintf("Invalid JSON: %v", err)) + } + + if rule.ID == "" { + rule.ID = generateRuleID() + } + rule.Enabled = true + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.SetRule(rule); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess(rule.ID, provider.GetOverridePath()) +} + +func runWindowrulesUpdate(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + ruleID := args[1] + ruleJSON := args[2] + + var rule windowrules.WindowRule + if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil { + writeRuleError(fmt.Sprintf("Invalid JSON: %v", err)) + } + + rule.ID = ruleID + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.SetRule(rule); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess(rule.ID, provider.GetOverridePath()) +} + +func runWindowrulesRemove(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + ruleID := args[1] + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.RemoveRule(ruleID); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess(ruleID, provider.GetOverridePath()) +} + +func runWindowrulesReorder(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + idsJSON := args[1] + + var ids []string + if err := json.Unmarshal([]byte(idsJSON), &ids); err != nil { + writeRuleError(fmt.Sprintf("Invalid JSON array: %v", err)) + } + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.ReorderRules(ids); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess("", provider.GetOverridePath()) +} + +func getWindowRulesProvider(compositor string) windowrules.WritableProvider { + switch compositor { + case "niri": + configDir, err := utils.ExpandPath("$HOME/.config/niri") + if err != nil { + return nil + } + return providers.NewNiriWritableProvider(configDir) + case "hyprland": + configDir, err := utils.ExpandPath("$HOME/.config/hypr") + if err != nil { + return nil + } + return providers.NewHyprlandWritableProvider(configDir) + default: + return nil + } +} + +func generateRuleID() string { + return fmt.Sprintf("wr_%d", time.Now().UnixNano()) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/dpms_client.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/dpms_client.go new file mode 100644 index 0000000..98f66d9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/dpms_client.go @@ -0,0 +1,339 @@ +package main + +import ( + "fmt" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type cmd struct { + fn func() + done chan error +} + +type dpmsClient struct { + display *wlclient.Display + ctx *wlclient.Context + powerMgr *wlr_output_power.ZwlrOutputPowerManagerV1 + outputs map[string]*outputState + mu sync.Mutex + syncRound int + done bool + err error + cmdq chan cmd + stopChan chan struct{} + wg sync.WaitGroup +} + +type outputState struct { + wlOutput *wlclient.Output + powerCtrl *wlr_output_power.ZwlrOutputPowerV1 + name string + mode uint32 + failed bool + waitCh chan struct{} + wantMode *uint32 +} + +func (c *dpmsClient) post(fn func()) { + done := make(chan error, 1) + select { + case c.cmdq <- cmd{fn: fn, done: done}: + <-done + case <-c.stopChan: + } +} + +func (c *dpmsClient) waylandActor() { + defer c.wg.Done() + for { + select { + case <-c.stopChan: + return + case cmd := <-c.cmdq: + cmd.fn() + close(cmd.done) + } + } +} + +func newDPMSClient() (*dpmsClient, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, fmt.Errorf("failed to connect to Wayland: %w", err) + } + + c := &dpmsClient{ + display: display, + ctx: display.Context(), + outputs: make(map[string]*outputState), + cmdq: make(chan cmd, 128), + stopChan: make(chan struct{}), + } + + c.wg.Add(1) + go c.waylandActor() + + registry, err := display.GetRegistry() + if err != nil { + display.Context().Close() + return nil, fmt.Errorf("failed to get registry: %w", err) + } + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case wlr_output_power.ZwlrOutputPowerManagerV1InterfaceName: + powerMgr := wlr_output_power.NewZwlrOutputPowerManagerV1(c.ctx) + version := min(e.Version, 1) + if err := registry.Bind(e.Name, e.Interface, version, powerMgr); err == nil { + c.powerMgr = powerMgr + } + + case "wl_output": + output := wlclient.NewOutput(c.ctx) + version := min(e.Version, 4) + if err := registry.Bind(e.Name, e.Interface, version, output); err == nil { + outputID := fmt.Sprintf("output-%d", output.ID()) + state := &outputState{ + wlOutput: output, + name: outputID, + } + + c.mu.Lock() + c.outputs[outputID] = state + c.mu.Unlock() + + output.SetNameHandler(func(ev wlclient.OutputNameEvent) { + c.mu.Lock() + delete(c.outputs, state.name) + state.name = ev.Name + c.outputs[ev.Name] = state + c.mu.Unlock() + }) + } + } + }) + + syncCallback, err := display.Sync() + if err != nil { + c.Close() + return nil, fmt.Errorf("failed to sync display: %w", err) + } + syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) { + c.handleSync() + }) + + for !c.done { + if err := c.ctx.Dispatch(); err != nil { + c.Close() + return nil, fmt.Errorf("dispatch error: %w", err) + } + } + + if c.err != nil { + c.Close() + return nil, c.err + } + + return c, nil +} + +func (c *dpmsClient) handleSync() { + c.syncRound++ + + switch c.syncRound { + case 1: + if c.powerMgr == nil { + c.err = fmt.Errorf("wlr-output-power-management protocol not supported by compositor") + c.done = true + return + } + + c.mu.Lock() + for _, state := range c.outputs { + powerCtrl, err := c.powerMgr.GetOutputPower(state.wlOutput) + if err != nil { + continue + } + state.powerCtrl = powerCtrl + + powerCtrl.SetModeHandler(func(e wlr_output_power.ZwlrOutputPowerV1ModeEvent) { + c.mu.Lock() + defer c.mu.Unlock() + if state.powerCtrl == nil { + return + } + state.mode = e.Mode + if state.wantMode != nil && e.Mode == *state.wantMode && state.waitCh != nil { + close(state.waitCh) + state.wantMode = nil + } + }) + + powerCtrl.SetFailedHandler(func(e wlr_output_power.ZwlrOutputPowerV1FailedEvent) { + c.mu.Lock() + defer c.mu.Unlock() + if state.powerCtrl == nil { + return + } + state.failed = true + if state.waitCh != nil { + close(state.waitCh) + state.wantMode = nil + } + }) + } + c.mu.Unlock() + + syncCallback, err := c.display.Sync() + if err != nil { + c.err = fmt.Errorf("failed to sync display: %w", err) + c.done = true + return + } + syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) { + c.handleSync() + }) + + default: + c.done = true + } +} + +func (c *dpmsClient) ListOutputs() []string { + c.mu.Lock() + defer c.mu.Unlock() + + names := make([]string, 0, len(c.outputs)) + for name := range c.outputs { + names = append(names, name) + } + return names +} + +func (c *dpmsClient) SetDPMS(outputName string, on bool) error { + var mode uint32 + if on { + mode = uint32(wlr_output_power.ZwlrOutputPowerV1ModeOn) + } else { + mode = uint32(wlr_output_power.ZwlrOutputPowerV1ModeOff) + } + + var setErr error + c.post(func() { + c.mu.Lock() + var waitStates []*outputState + + if outputName == "" || outputName == "all" { + if len(c.outputs) == 0 { + c.mu.Unlock() + setErr = fmt.Errorf("no outputs found") + return + } + + for _, state := range c.outputs { + if state.powerCtrl == nil { + continue + } + state.wantMode = &mode + state.waitCh = make(chan struct{}) + state.failed = false + waitStates = append(waitStates, state) + state.powerCtrl.SetMode(mode) + } + } else { + state, ok := c.outputs[outputName] + if !ok { + c.mu.Unlock() + setErr = fmt.Errorf("output not found: %s", outputName) + return + } + if state.powerCtrl == nil { + c.mu.Unlock() + setErr = fmt.Errorf("output %s has nil powerCtrl", outputName) + return + } + state.wantMode = &mode + state.waitCh = make(chan struct{}) + state.failed = false + waitStates = append(waitStates, state) + state.powerCtrl.SetMode(mode) + } + c.mu.Unlock() + + deadline := time.Now().Add(10 * time.Second) + + for _, state := range waitStates { + c.mu.Lock() + ch := state.waitCh + c.mu.Unlock() + + done := false + for !done { + if err := c.ctx.Dispatch(); err != nil { + setErr = fmt.Errorf("dispatch error: %w", err) + return + } + + select { + case <-ch: + c.mu.Lock() + if state.failed { + setErr = fmt.Errorf("compositor reported failed for %s", state.name) + c.mu.Unlock() + return + } + c.mu.Unlock() + done = true + default: + if time.Now().After(deadline) { + setErr = fmt.Errorf("timeout waiting for mode change on %s", state.name) + return + } + time.Sleep(10 * time.Millisecond) + } + } + } + + c.mu.Lock() + for _, state := range waitStates { + if state.powerCtrl != nil { + state.powerCtrl.Destroy() + state.powerCtrl = nil + } + } + c.mu.Unlock() + + c.display.Roundtrip() + }) + + return setErr +} + +func (c *dpmsClient) Close() { + close(c.stopChan) + c.wg.Wait() + + c.mu.Lock() + defer c.mu.Unlock() + + for _, state := range c.outputs { + if state.powerCtrl != nil { + state.powerCtrl.Destroy() + } + } + c.outputs = nil + + if c.powerMgr != nil { + c.powerMgr.Destroy() + c.powerMgr = nil + } + + if c.display != nil { + c.ctx.Close() + c.display = nil + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/immutable_policy.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/immutable_policy.go new file mode 100644 index 0000000..5529c9f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/immutable_policy.go @@ -0,0 +1,271 @@ +package main + +import ( + "bufio" + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + "github.com/spf13/cobra" +) + +const ( + cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json" + cliPolicyAdminPath = "/etc/dms/cli-policy.json" +) + +var ( + immutablePolicyOnce sync.Once + immutablePolicy immutableCommandPolicy + immutablePolicyErr error +) + +//go:embed assets/cli-policy.default.json +var defaultCLIPolicyJSON []byte + +type immutableCommandPolicy struct { + ImmutableSystem bool + ImmutableReason string + BlockedCommands []string + Message string +} + +type cliPolicyFile struct { + PolicyVersion int `json:"policy_version"` + ImmutableSystem *bool `json:"immutable_system"` + BlockedCommands *[]string `json:"blocked_commands"` + Message *string `json:"message"` +} + +func normalizeCommandSpec(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized = strings.TrimPrefix(normalized, "dms ") + return strings.Join(strings.Fields(normalized), " ") +} + +func normalizeBlockedCommands(raw []string) []string { + normalized := make([]string, 0, len(raw)) + seen := make(map[string]bool) + + for _, cmd := range raw { + spec := normalizeCommandSpec(cmd) + if spec == "" || seen[spec] { + continue + } + seen[spec] = true + normalized = append(normalized, spec) + } + + return normalized +} + +func commandBlockedByPolicy(commandPath string, blocked []string) bool { + normalizedPath := normalizeCommandSpec(commandPath) + if normalizedPath == "" { + return false + } + + for _, entry := range blocked { + spec := normalizeCommandSpec(entry) + if spec == "" { + continue + } + if normalizedPath == spec || strings.HasPrefix(normalizedPath, spec+" ") { + return true + } + } + + return false +} + +func loadPolicyFile(path string) (*cliPolicyFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + var policy cliPolicyFile + if err := json.Unmarshal(data, &policy); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + + return &policy, nil +} + +func mergePolicyFile(base *immutableCommandPolicy, path string) error { + policyFile, err := loadPolicyFile(path) + if err != nil { + return err + } + if policyFile == nil { + return nil + } + + if policyFile.ImmutableSystem != nil { + base.ImmutableSystem = *policyFile.ImmutableSystem + } + if policyFile.BlockedCommands != nil { + base.BlockedCommands = normalizeBlockedCommands(*policyFile.BlockedCommands) + } + if policyFile.Message != nil { + msg := strings.TrimSpace(*policyFile.Message) + if msg != "" { + base.Message = msg + } + } + + return nil +} + +func readOSReleaseMap(path string) map[string]string { + values := make(map[string]string) + + file, err := os.Open(path) + if err != nil { + return values + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.ToUpper(strings.TrimSpace(parts[0])) + value := strings.Trim(strings.TrimSpace(parts[1]), "\"") + values[key] = strings.ToLower(value) + } + + return values +} + +func hasAnyToken(text string, tokens ...string) bool { + if text == "" { + return false + } + for _, token := range tokens { + if strings.Contains(text, token) { + return true + } + } + return false +} + +func detectImmutableSystem() (bool, string) { + if _, err := os.Stat("/run/ostree-booted"); err == nil { + return true, "/run/ostree-booted is present" + } + + osRelease := readOSReleaseMap("/etc/os-release") + if len(osRelease) == 0 { + return false, "" + } + + id := osRelease["ID"] + idLike := osRelease["ID_LIKE"] + variantID := osRelease["VARIANT_ID"] + name := osRelease["NAME"] + prettyName := osRelease["PRETTY_NAME"] + + immutableIDs := map[string]bool{ + "bluefin": true, + "bazzite": true, + "silverblue": true, + "kinoite": true, + "sericea": true, + "onyx": true, + "aurora": true, + "fedora-iot": true, + "fedora-coreos": true, + } + if immutableIDs[id] { + return true, "os-release ID=" + id + } + + markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"} + if hasAnyToken(variantID, markers...) { + return true, "os-release VARIANT_ID=" + variantID + } + if hasAnyToken(idLike, "ostree", "rpm-ostree") { + return true, "os-release ID_LIKE=" + idLike + } + if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) { + return true, "os-release identifies an atomic/ostree variant" + } + + return false, "" +} + +func getImmutablePolicy() (*immutableCommandPolicy, error) { + immutablePolicyOnce.Do(func() { + detectedImmutable, reason := detectImmutableSystem() + immutablePolicy = immutableCommandPolicy{ + ImmutableSystem: detectedImmutable, + ImmutableReason: reason, + BlockedCommands: []string{"greeter install", "greeter enable", "setup"}, + Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.", + } + + var defaultPolicy cliPolicyFile + if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil { + immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err) + return + } + if defaultPolicy.BlockedCommands != nil { + immutablePolicy.BlockedCommands = normalizeBlockedCommands(*defaultPolicy.BlockedCommands) + } + if defaultPolicy.Message != nil { + msg := strings.TrimSpace(*defaultPolicy.Message) + if msg != "" { + immutablePolicy.Message = msg + } + } + + if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil { + immutablePolicyErr = err + return + } + if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil { + immutablePolicyErr = err + return + } + }) + + if immutablePolicyErr != nil { + return nil, immutablePolicyErr + } + return &immutablePolicy, nil +} + +func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error { + policy, err := getImmutablePolicy() + if err != nil { + return err + } + if !policy.ImmutableSystem { + return nil + } + + commandPath := normalizeCommandSpec(cmd.CommandPath()) + if !commandBlockedByPolicy(commandPath, policy.BlockedCommands) { + return nil + } + + reason := "" + if policy.ImmutableReason != "" { + reason = "Detected immutable system: " + policy.ImmutableReason + "\n" + } + + return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/main.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/main.go new file mode 100644 index 0000000..c818148 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/main.go @@ -0,0 +1,43 @@ +//go:build !distro_binary + +package main + +import ( + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +var Version = "dev" + +func init() { + runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") + runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") + runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") + runCmd.Flags().MarkHidden("daemon-child") + + greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) + authCmd.AddCommand(authSyncCmd) + setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) + updateCmd.AddCommand(updateCheckCmd) + pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) + rootCmd.AddCommand(getCommonCommands()...) + + rootCmd.AddCommand(authCmd) + rootCmd.AddCommand(updateCmd) + + rootCmd.SetHelpTemplate(getHelpTemplate()) +} + +func main() { + clipboard.MaybeServeAndExit() + + if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) { + log.Fatal("This program should not be run as root. Exiting.") + } + + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/main_distro.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/main_distro.go new file mode 100644 index 0000000..2b544ff --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/main_distro.go @@ -0,0 +1,40 @@ +//go:build distro_binary + +package main + +import ( + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +var Version = "dev" + +func init() { + runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") + runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") + runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") + runCmd.Flags().MarkHidden("daemon-child") + + greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) + authCmd.AddCommand(authSyncCmd) + setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) + pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) + rootCmd.AddCommand(getCommonCommands()...) + rootCmd.AddCommand(authCmd) + + rootCmd.SetHelpTemplate(getHelpTemplate()) +} + +func main() { + clipboard.MaybeServeAndExit() + + if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) { + log.Fatal("This program should not be run as root. Exiting.") + } + + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/randr_client.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/randr_client.go new file mode 100644 index 0000000..1a149ca --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/randr_client.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type randrOutput struct { + Name string `json:"name"` + Scale float64 `json:"scale"` + Width int32 `json:"width"` + Height int32 `json:"height"` + Refresh int32 `json:"refresh"` + Enabled bool `json:"enabled"` +} + +type randrHead struct { + name string + enabled bool + scale float64 + currentModeID uint32 + modeIDs []uint32 +} + +type randrMode struct { + width int32 + height int32 + refresh int32 +} + +type randrClient struct { + display *wlclient.Display + ctx *wlclient.Context + manager *wlr_output_management.ZwlrOutputManagerV1 + heads map[uint32]*randrHead + modes map[uint32]*randrMode + done bool + err error +} + +func queryRandr() ([]randrOutput, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, fmt.Errorf("failed to connect to Wayland: %w", err) + } + + c := &randrClient{ + display: display, + ctx: display.Context(), + heads: make(map[uint32]*randrHead), + modes: make(map[uint32]*randrMode), + } + defer c.ctx.Close() + + registry, err := display.GetRegistry() + if err != nil { + return nil, fmt.Errorf("failed to get registry: %w", err) + } + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName { + mgr := wlr_output_management.NewZwlrOutputManagerV1(c.ctx) + version := min(e.Version, 4) + + mgr.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + c.handleHead(e) + }) + + mgr.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) { + c.done = true + }) + + if err := registry.Bind(e.Name, e.Interface, version, mgr); err == nil { + c.manager = mgr + } + } + }) + + // First roundtrip: discover globals and bind manager + syncCallback, err := display.Sync() + if err != nil { + return nil, fmt.Errorf("failed to sync display: %w", err) + } + syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) { + if c.manager == nil { + c.err = fmt.Errorf("zwlr_output_manager_v1 protocol not supported by compositor") + c.done = true + } + // Otherwise wait for manager's DoneHandler + }) + + for !c.done { + if err := c.ctx.Dispatch(); err != nil { + return nil, fmt.Errorf("dispatch error: %w", err) + } + } + + if c.err != nil { + return nil, c.err + } + + return c.buildOutputs(), nil +} + +func (c *randrClient) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + handle := e.Head + headID := handle.ID() + + head := &randrHead{ + modeIDs: make([]uint32, 0), + } + c.heads[headID] = head + + handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) { + head.name = e.Name + }) + + handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) { + head.enabled = e.Enabled != 0 + }) + + handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) { + head.scale = e.Scale + }) + + handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) { + head.currentModeID = e.Mode.ID() + }) + + handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) { + modeHandle := e.Mode + modeID := modeHandle.ID() + + head.modeIDs = append(head.modeIDs, modeID) + + mode := &randrMode{} + c.modes[modeID] = mode + + modeHandle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) { + mode.width = e.Width + mode.height = e.Height + }) + + modeHandle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) { + mode.refresh = e.Refresh + }) + }) +} + +func (c *randrClient) buildOutputs() []randrOutput { + outputs := make([]randrOutput, 0, len(c.heads)) + + for _, head := range c.heads { + out := randrOutput{ + Name: head.name, + Scale: head.scale, + Enabled: head.enabled, + } + + if mode, ok := c.modes[head.currentModeID]; ok { + out.Width = mode.width + out.Height = mode.height + out.Refresh = mode.refresh + } + + outputs = append(outputs, out) + } + + return outputs +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/server_client.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/server_client.go new file mode 100644 index 0000000..871d21d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/server_client.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func sendServerRequest(req models.Request) (*models.Response[any], error) { + socketPath := getServerSocketPath() + + conn, err := net.Dial("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("failed to connect to server (is it running?): %w", err) + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + scanner.Scan() // discard initial capabilities message + + reqData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(reqData); err != nil { + return nil, fmt.Errorf("failed to write request: %w", err) + } + + if _, err := conn.Write([]byte("\n")); err != nil { + return nil, fmt.Errorf("failed to write newline: %w", err) + } + + if !scanner.Scan() { + return nil, fmt.Errorf("failed to read response") + } + + var resp models.Response[any] + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &resp, nil +} + +// sendServerRequestFireAndForget sends a request without waiting for a response. +// Useful for commands that trigger UI or async operations. +func sendServerRequestFireAndForget(req models.Request) error { + socketPath := getServerSocketPath() + + conn, err := net.Dial("unix", socketPath) + if err != nil { + return fmt.Errorf("failed to connect to server (is it running?): %w", err) + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + scanner.Scan() // discard initial capabilities message + + reqData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(reqData); err != nil { + return fmt.Errorf("failed to write request: %w", err) + } + + if _, err := conn.Write([]byte("\n")); err != nil { + return fmt.Errorf("failed to write newline: %w", err) + } + + return nil +} + +// tryServerRequest attempts to send a request but returns false if server unavailable. +// Does not log errors - caller can decide what to do on failure. +func tryServerRequest(req models.Request) (*models.Response[any], bool) { + resp, err := sendServerRequest(req) + if err != nil { + return nil, false + } + return resp, true +} + +func getServerSocketPath() string { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = os.TempDir() + } + + entries, err := os.ReadDir(runtimeDir) + if err != nil { + return filepath.Join(runtimeDir, "danklinux.sock") + } + + for _, entry := range entries { + name := entry.Name() + if name == "danklinux.sock" { + return filepath.Join(runtimeDir, name) + } + if len(name) > 10 && name[:10] == "danklinux-" && filepath.Ext(name) == ".sock" { + return filepath.Join(runtimeDir, name) + } + } + + return server.GetSocketPath() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/shell.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/shell.go new file mode 100644 index 0000000..7ce2003 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/shell.go @@ -0,0 +1,739 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" +) + +type ipcTargets map[string]map[string][]string + +// getProcessExitCode returns the exit code from a ProcessState. +// For normal exits, returns the exit code directly. +// For signal termination, returns 128 + signal number (Unix convention). +func getProcessExitCode(state *os.ProcessState) int { + if state == nil { + return 1 + } + if code := state.ExitCode(); code != -1 { + return code + } + // Process was killed by signal - extract signal number + if status, ok := state.Sys().(syscall.WaitStatus); ok { + if status.Signaled() { + return 128 + int(status.Signal()) + } + } + return 1 +} + +var isSessionManaged bool + +func execDetachedRestart(targetPID int) { + selfPath, err := os.Executable() + if err != nil { + return + } + + cmd := exec.Command(selfPath, "restart-detached", strconv.Itoa(targetPID)) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} + +func runDetachedRestart(targetPIDStr string) { + targetPID, err := strconv.Atoi(targetPIDStr) + if err != nil { + return + } + + time.Sleep(200 * time.Millisecond) + + proc, err := os.FindProcess(targetPID) + if err == nil { + proc.Signal(syscall.SIGTERM) + } + + time.Sleep(500 * time.Millisecond) + + killShell() + runShellDaemon(false) +} + +func getRuntimeDir() string { + if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" { + return runtime + } + return os.TempDir() +} + +func hasSystemdRun() bool { + _, err := exec.LookPath("systemd-run") + return err == nil +} + +func getPIDFilePath() string { + return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid())) +} + +func writePIDFile(childPID int) error { + pidFile := getPIDFilePath() + return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0o644) +} + +func removePIDFile() { + pidFile := getPIDFilePath() + os.Remove(pidFile) +} + +func getAllDMSPIDs() []int { + dir := getRuntimeDir() + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var pids []int + + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") { + continue + } + + pidFile := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(pidFile) + if err != nil { + continue + } + + childPID, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + os.Remove(pidFile) + continue + } + + proc, err := os.FindProcess(childPID) + if err != nil { + os.Remove(pidFile) + continue + } + + if err := proc.Signal(syscall.Signal(0)); err != nil { + os.Remove(pidFile) + continue + } + + pids = append(pids, childPID) + + parentPIDStr := strings.TrimPrefix(entry.Name(), "danklinux-") + parentPIDStr = strings.TrimSuffix(parentPIDStr, ".pid") + if parentPID, err := strconv.Atoi(parentPIDStr); err == nil { + if parentProc, err := os.FindProcess(parentPID); err == nil { + if err := parentProc.Signal(syscall.Signal(0)); err == nil { + pids = append(pids, parentPID) + } + } + } + } + + return pids +} + +func runShellInteractive(session bool) { + isSessionManaged = session + go printASCII() + fmt.Fprintf(os.Stderr, "dms %s\n", Version) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + socketPath := server.GetSocketPath() + + configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") + if err := os.WriteFile(configStateFile, []byte(configPath), 0o644); err != nil { + log.Warnf("Failed to write config state file: %v", err) + } + defer os.Remove(configStateFile) + + errChan := make(chan error, 2) + + go func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("server panic: %v", r) + } + }() + server.CLIVersion = Version + if err := server.Start(false); err != nil { + errChan <- fmt.Errorf("server error: %w", err) + } + }() + + log.Infof("Spawning quickshell with -p %s", configPath) + + cmd := exec.CommandContext(ctx, "qs", "-p", configPath) + cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath) + if os.Getenv("QT_LOGGING_RULES") == "" { + if qtRules := log.GetQtLoggingRules(); qtRules != "" { + cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules) + } + } + + // ! TODO - remove when QS 0.3 is up and we can use the pragma + cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms") + + if isSessionManaged && hasSystemdRun() { + cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope") + } + + homeDir, err := os.UserHomeDir() + if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" { + if !strings.HasPrefix(configPath, homeDir) { + cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1") + } + } + + if os.Getenv("QT_QPA_PLATFORMTHEME") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME=gtk3") + } + if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3") + } + if os.Getenv("QT_QPA_PLATFORM") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") + } + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + log.Fatalf("Error starting quickshell: %v", err) + } + + // Write PID file for the quickshell child process + if err := writePIDFile(cmd.Process.Pid); err != nil { + log.Warnf("Failed to write PID file: %v", err) + } + defer removePIDFile() + + defer func() { + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1) + + go func() { + if err := cmd.Wait(); err != nil { + errChan <- fmt.Errorf("quickshell exited: %w", err) + } else { + errChan <- fmt.Errorf("quickshell exited") + } + }() + + for { + select { + case sig := <-sigChan: + if sig == syscall.SIGUSR1 { + if isSessionManaged { + log.Infof("Received SIGUSR1, exiting for systemd restart...") + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + os.Exit(1) + } + log.Infof("Received SIGUSR1, spawning detached restart process...") + execDetachedRestart(os.Getpid()) + return + } + + // Check if qs already crashed before we got SIGTERM (systemd sends SIGTERM when D-Bus name is released) + select { + case <-errChan: + cancel() + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + case <-time.After(500 * time.Millisecond): + } + + log.Infof("\nReceived signal %v, shutting down...", sig) + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + return + + case err := <-errChan: + log.Error(err) + cancel() + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + } + } +} + +func restartShell() { + pids := getAllDMSPIDs() + + if len(pids) == 0 { + log.Info("No running DMS shell instances found. Starting daemon...") + runShellDaemon(false) + return + } + + currentPid := os.Getpid() + uniquePids := make(map[int]bool) + + for _, pid := range pids { + if pid != currentPid { + uniquePids[pid] = true + } + } + + for pid := range uniquePids { + proc, err := os.FindProcess(pid) + if err != nil { + log.Errorf("Error finding process %d: %v", pid, err) + continue + } + + if err := proc.Signal(syscall.Signal(0)); err != nil { + continue + } + + if err := proc.Signal(syscall.SIGUSR1); err != nil { + log.Errorf("Error sending SIGUSR1 to process %d: %v", pid, err) + } else { + log.Infof("Sent SIGUSR1 to DMS process with PID %d", pid) + } + } +} + +func killShell() { + pids := getAllDMSPIDs() + + if len(pids) == 0 { + log.Info("No running DMS shell instances found.") + return + } + + currentPid := os.Getpid() + uniquePids := make(map[int]bool) + + for _, pid := range pids { + if pid != currentPid { + uniquePids[pid] = true + } + } + + for pid := range uniquePids { + proc, err := os.FindProcess(pid) + if err != nil { + log.Errorf("Error finding process %d: %v", pid, err) + continue + } + + if err := proc.Signal(syscall.Signal(0)); err != nil { + continue + } + + if err := proc.Kill(); err != nil { + log.Errorf("Error killing process %d: %v", pid, err) + } else { + log.Infof("Killed DMS process with PID %d", pid) + } + } + + dir := getRuntimeDir() + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".pid") { + pidFile := filepath.Join(dir, entry.Name()) + os.Remove(pidFile) + } + } +} + +func runShellDaemon(session bool) { + isSessionManaged = session + isDaemonChild := slices.Contains(os.Args, "--daemon-child") + + if !isDaemonChild { + fmt.Fprintf(os.Stderr, "dms %s\n", Version) + + cmd := exec.Command(os.Args[0], "run", "-d", "--daemon-child") + cmd.Env = os.Environ() + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + if err := cmd.Start(); err != nil { + log.Fatalf("Error starting daemon: %v", err) + } + + log.Infof("DMS shell daemon started (PID: %d)", cmd.Process.Pid) + return + } + + fmt.Fprintf(os.Stderr, "dms %s\n", Version) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + socketPath := server.GetSocketPath() + + configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") + if err := os.WriteFile(configStateFile, []byte(configPath), 0o644); err != nil { + log.Warnf("Failed to write config state file: %v", err) + } + defer os.Remove(configStateFile) + + errChan := make(chan error, 2) + + go func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("server panic: %v", r) + } + }() + server.CLIVersion = Version + if err := server.Start(false); err != nil { + errChan <- fmt.Errorf("server error: %w", err) + } + }() + + log.Infof("Spawning quickshell with -p %s", configPath) + + cmd := exec.CommandContext(ctx, "qs", "-p", configPath) + cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath) + if os.Getenv("QT_LOGGING_RULES") == "" { + if qtRules := log.GetQtLoggingRules(); qtRules != "" { + cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules) + } + } + + // ! TODO - remove when QS 0.3 is up and we can use the pragma + cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms") + + if isSessionManaged && hasSystemdRun() { + cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope") + } + + homeDir, err := os.UserHomeDir() + if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" { + if !strings.HasPrefix(configPath, homeDir) { + cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1") + } + } + + if os.Getenv("QT_QPA_PLATFORMTHEME") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME=gtk3") + } + if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3") + } + if os.Getenv("QT_QPA_PLATFORM") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") + } + + devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) + if err != nil { + log.Fatalf("Error opening /dev/null: %v", err) + } + defer devNull.Close() + + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + + if err := cmd.Start(); err != nil { + log.Fatalf("Error starting daemon: %v", err) + } + + // Write PID file for the quickshell child process + if err := writePIDFile(cmd.Process.Pid); err != nil { + log.Warnf("Failed to write PID file: %v", err) + } + defer removePIDFile() + + defer func() { + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1) + + go func() { + if err := cmd.Wait(); err != nil { + errChan <- fmt.Errorf("quickshell exited: %w", err) + } else { + errChan <- fmt.Errorf("quickshell exited") + } + }() + + for { + select { + case sig := <-sigChan: + if sig == syscall.SIGUSR1 { + if isSessionManaged { + log.Infof("Received SIGUSR1, exiting for systemd restart...") + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + os.Exit(1) + } + log.Infof("Received SIGUSR1, spawning detached restart process...") + execDetachedRestart(os.Getpid()) + return + } + + // Check if qs already crashed before we got SIGTERM (systemd sends SIGTERM when D-Bus name is released) + select { + case <-errChan: + cancel() + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + case <-time.After(500 * time.Millisecond): + } + + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + return + + case <-errChan: + cancel() + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + } + } +} + +var qsHasAnyDisplay = sync.OnceValue(func() bool { + out, err := exec.Command("qs", "ipc", "--help").Output() + if err != nil { + return false + } + return strings.Contains(string(out), "--any-display") +}) + +func parseTargetsFromIPCShowOutput(output string) ipcTargets { + targets := make(ipcTargets) + var currentTarget string + for line := range strings.SplitSeq(output, "\n") { + if after, ok := strings.CutPrefix(line, "target "); ok { + currentTarget = strings.TrimSpace(after) + targets[currentTarget] = make(map[string][]string) + } + if strings.HasPrefix(line, " function") && currentTarget != "" { + argsList := []string{} + currentFunc := strings.TrimPrefix(line, " function ") + funcDef := strings.SplitN(currentFunc, "(", 2) + argList := strings.SplitN(funcDef[1], ")", 2)[0] + args := strings.Split(argList, ",") + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + argsList = append(argsList, funcDef[0]) + for _, arg := range args { + argName := strings.SplitN(strings.TrimSpace(arg), ":", 2)[0] + argsList = append(argsList, argName) + } + targets[currentTarget][funcDef[0]] = argsList + } else { + targets[currentTarget][funcDef[0]] = make([]string, 0) + } + } + } + return targets +} + +func getShellIPCCompletions(args []string, _ string) []string { + cmdArgs := []string{"ipc"} + if qsHasAnyDisplay() { + cmdArgs = append(cmdArgs, "--any-display") + } + cmdArgs = append(cmdArgs, "-p", configPath, "show") + cmd := exec.Command("qs", cmdArgs...) + var targets ipcTargets + + if output, err := cmd.Output(); err == nil { + targets = parseTargetsFromIPCShowOutput(string(output)) + } else { + log.Debugf("Error getting IPC show output for completions: %v", err) + return nil + } + + if len(args) > 0 && args[0] == "call" { + args = args[1:] + } + + if len(args) == 0 { + targetNames := make([]string, 0) + targetNames = append(targetNames, "call") + for k := range targets { + targetNames = append(targetNames, k) + } + return targetNames + } + if len(args) == 1 { + if targetFuncs, ok := targets[args[0]]; ok { + funcNames := make([]string, 0) + for k := range targetFuncs { + funcNames = append(funcNames, k) + } + return funcNames + } + return nil + } + if len(args) <= len(targets[args[0]]) { + funcArgs := targets[args[0]][args[1]] + if len(funcArgs) >= len(args) { + return []string{fmt.Sprintf("[%s]", funcArgs[len(args)-1])} + } + } + + return nil +} + +func getFirstDMSPID() (int, bool) { + dir := getRuntimeDir() + entries, err := os.ReadDir(dir) + if err != nil { + return 0, false + } + + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + continue + } + + proc, err := os.FindProcess(pid) + if err != nil { + continue + } + + if proc.Signal(syscall.Signal(0)) != nil { + continue + } + + return pid, true + } + + return 0, false +} + +func runShellIPCCommand(args []string) { + if len(args) == 0 { + printIPCHelp() + return + } + + if args[0] != "call" { + args = append([]string{"call"}, args...) + } + + cmdArgs := []string{"ipc"} + + switch pid, ok := getFirstDMSPID(); { + case ok: + cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid)) + default: + if err := findConfig(nil, nil); err != nil { + log.Fatalf("Error finding config: %v", err) + } + // ! TODO - remove check when QS 0.3 is released + if qsHasAnyDisplay() { + cmdArgs = append(cmdArgs, "--any-display") + } + cmdArgs = append(cmdArgs, "-p", configPath) + } + + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command("qs", cmdArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + log.Fatalf("Error running IPC command: %v", err) + } +} + +func printIPCHelp() { + fmt.Println("Usage: dms ipc <target> <function> [args...]") + fmt.Println() + + cmdArgs := []string{"ipc"} + if qsHasAnyDisplay() { + cmdArgs = append(cmdArgs, "--any-display") + } + cmdArgs = append(cmdArgs, "-p", configPath, "show") + cmd := exec.Command("qs", cmdArgs...) + + output, err := cmd.Output() + if err != nil { + fmt.Println("Could not retrieve available IPC targets (is DMS running?)") + return + } + + targets := parseTargetsFromIPCShowOutput(string(output)) + if len(targets) == 0 { + fmt.Println("No IPC targets available") + return + } + + fmt.Println("Targets:") + + targetNames := make([]string, 0, len(targets)) + for name := range targets { + targetNames = append(targetNames, name) + } + slices.Sort(targetNames) + + for _, targetName := range targetNames { + funcs := targets[targetName] + funcNames := make([]string, 0, len(funcs)) + for fn := range funcs { + funcNames = append(funcNames, fn) + } + slices.Sort(funcNames) + fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", ")) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/ui.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/ui.go new file mode 100644 index 0000000..c0b50e6 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/ui.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/tui" + "github.com/charmbracelet/lipgloss" +) + +func printASCII() { + fmt.Print(getThemedASCII()) +} + +func getThemedASCII() string { + theme := tui.TerminalTheme() + + logo := ` +██████╗ █████╗ ███╗ ██╗██╗ ██╗ +██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝ +██║ ██║███████║██╔██╗ ██║█████╔╝ +██║ ██║██╔══██║██║╚██╗██║██╔═██╗ +██████╔╝██║ ██║██║ ╚████║██║ ██╗ +╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝` + + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true) + + return style.Render(logo) + "\n" +} + +func getHelpTemplate() string { + return getThemedASCII() + ` +{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/utils.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/utils.go new file mode 100644 index 0000000..9459e41 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/cmd/dms/utils.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "os/exec" + "slices" + "strings" +) + +// isReadOnlyCommand returns true if the CLI args indicate a command that is +// safe to run as root (e.g. shell completion, help). +func isReadOnlyCommand(args []string) bool { + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "-") { + continue + } + switch arg { + case "completion", "help", "__complete": + return true + } + return false + } + return false +} + +func isArchPackageInstalled(packageName string) bool { + cmd := exec.Command("pacman", "-Q", packageName) + err := cmd.Run() + return err == nil +} + +type systemdServiceState struct { + Name string + EnabledState string + NeedsDisable bool + Exists bool +} + +// checkSystemdServiceEnabled returns (state, should_disable, error) for a systemd service +func checkSystemdServiceEnabled(serviceName string) (string, bool, error) { + cmd := exec.Command("systemctl", "is-enabled", serviceName) + output, err := cmd.Output() + + stateStr := strings.TrimSpace(string(output)) + + if err != nil { + knownStates := []string{"disabled", "masked", "masked-runtime", "not-found", "enabled", "enabled-runtime", "static", "indirect", "alias"} + isKnownState := slices.Contains(knownStates, stateStr) + + if !isKnownState { + return stateStr, false, fmt.Errorf("systemctl is-enabled failed: %w (output: %s)", err, stateStr) + } + } + + shouldDisable := false + switch stateStr { + case "enabled", "enabled-runtime", "static", "indirect", "alias": + shouldDisable = true + case "disabled", "masked", "masked-runtime", "not-found": + shouldDisable = false + default: + shouldDisable = true + } + + return stateStr, shouldDisable, nil +} + +func getSystemdServiceState(serviceName string) (*systemdServiceState, error) { + state := &systemdServiceState{ + Name: serviceName, + Exists: false, + } + + enabledState, needsDisable, err := checkSystemdServiceEnabled(serviceName) + if err != nil { + return nil, fmt.Errorf("failed to check enabled state: %w", err) + } + + state.EnabledState = enabledState + state.NeedsDisable = needsDisable + + if enabledState == "not-found" { + state.Exists = false + return state, nil + } + + state.Exists = true + return state, nil +} |