initial commit

This commit is contained in:
tumillanino
2025-11-12 23:59:00 +11:00
commit 991cca06bf
274 changed files with 70484 additions and 0 deletions

627
internal/config/deployer.go Normal file
View File

@@ -0,0 +1,627 @@
package config
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/AvengeMedia/danklinux/internal/deps"
)
type ConfigDeployer struct {
logChan chan<- string
}
type DeploymentResult struct {
ConfigType string
Path string
BackupPath string
Deployed bool
Error error
}
func NewConfigDeployer(logChan chan<- string) *ConfigDeployer {
return &ConfigDeployer{
logChan: logChan,
}
}
func (cd *ConfigDeployer) log(message string) {
if cd.logChan != nil {
cd.logChan <- message
}
}
// DeployConfigurations deploys all necessary configurations based on the chosen window manager
func (cd *ConfigDeployer) DeployConfigurations(ctx context.Context, wm deps.WindowManager) ([]DeploymentResult, error) {
return cd.DeployConfigurationsWithTerminal(ctx, wm, deps.TerminalGhostty)
}
// DeployConfigurationsWithTerminal deploys all necessary configurations based on chosen window manager and terminal
func (cd *ConfigDeployer) DeployConfigurationsWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelective(ctx, wm, terminal, nil, nil)
}
func (cd *ConfigDeployer) DeployConfigurationsSelective(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelectiveWithReinstalls(ctx, wm, terminal, installedDeps, replaceConfigs, nil)
}
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool) ([]DeploymentResult, error) {
var results []DeploymentResult
shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil {
return true
}
replace, exists := replaceConfigs[configType]
return !exists || replace
}
switch wm {
case deps.WindowManagerNiri:
if shouldReplaceConfig("Niri") {
result, err := cd.deployNiriConfig(terminal)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Niri config: %w", err)
}
}
case deps.WindowManagerHyprland:
if shouldReplaceConfig("Hyprland") {
result, err := cd.deployHyprlandConfig(terminal)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
}
}
}
switch terminal {
case deps.TerminalGhostty:
if shouldReplaceConfig("Ghostty") {
ghosttyResults, err := cd.deployGhosttyConfig()
results = append(results, ghosttyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Ghostty config: %w", err)
}
}
case deps.TerminalKitty:
if shouldReplaceConfig("Kitty") {
kittyResults, err := cd.deployKittyConfig()
results = append(results, kittyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Kitty config: %w", err)
}
}
case deps.TerminalAlacritty:
if shouldReplaceConfig("Alacritty") {
alacrittyResults, err := cd.deployAlacrittyConfig()
results = append(results, alacrittyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Alacritty config: %w", err)
}
}
}
// Check if zsh was installed and deploy .zshrc configuration
if installedDeps != nil {
for _, dep := range installedDeps {
if dep.Name == "zsh" && (dep.Status == deps.StatusInstalled || dep.Status == deps.StatusNeedsUpdate) {
if shouldReplaceConfig(".zshrc") {
zshResult, err := cd.deployZshrcConfig()
results = append(results, zshResult)
if err != nil {
return results, fmt.Errorf("failed to deploy .zshrc config: %w", err)
}
}
break
}
}
}
return results, nil
}
// deployNiriConfig handles Niri configuration deployment with backup and merging
func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Niri",
Path: filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Niri configuration")
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
// Detect polkit agent path
polkitPath, err := cd.detectPolkitAgent()
if err != nil {
cd.log(fmt.Sprintf("Warning: Could not detect polkit agent: %v", err))
polkitPath = "/usr/lib/mate-polkit/polkit-mate-authentication-agent-1" // fallback
}
// Determine terminal command based on choice
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty" // fallback to ghostty
}
newConfig := strings.ReplaceAll(NiriConfig, "{{POLKIT_AGENT_PATH}}", polkitPath)
newConfig = strings.ReplaceAll(newConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
// If there was an existing config, merge the output sections
if existingConfig != "" {
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err))
} else {
newConfig = mergedConfig
cd.log("Successfully merged existing output sections")
}
}
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Niri configuration")
return result, nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Ghostty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Ghostty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Ghostty configuration")
results = append(results, mainResult)
colorResult := DeploymentResult{
ConfigType: "Ghostty Colors",
Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config-dankcolors"),
}
if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0644); err != nil {
colorResult.Error = fmt.Errorf("failed to write color config: %w", err)
return results, colorResult.Error
}
colorResult.Deployed = true
cd.log("Successfully deployed Ghostty color configuration")
results = append(results, colorResult)
return results, nil
}
func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Kitty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Kitty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Kitty configuration")
results = append(results, mainResult)
themeResult := DeploymentResult{
ConfigType: "Kitty Theme",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"),
}
if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error
}
themeResult.Deployed = true
cd.log("Successfully deployed Kitty theme configuration")
results = append(results, themeResult)
tabsResult := DeploymentResult{
ConfigType: "Kitty Tabs",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"),
}
if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0644); err != nil {
tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err)
return results, tabsResult.Error
}
tabsResult.Deployed = true
cd.log("Successfully deployed Kitty tabs configuration")
results = append(results, tabsResult)
return results, nil
}
func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Alacritty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Alacritty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Alacritty configuration")
results = append(results, mainResult)
themeResult := DeploymentResult{
ConfigType: "Alacritty Theme",
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"),
}
if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error
}
themeResult.Deployed = true
cd.log("Successfully deployed Alacritty theme configuration")
results = append(results, themeResult)
return results, nil
}
// detectPolkitAgent tries to find the polkit authentication agent on the system
// Prioritizes mate-polkit paths since that's what we install
func (cd *ConfigDeployer) detectPolkitAgent() (string, error) {
// Prioritize mate-polkit paths first
matePaths := []string{
"/usr/libexec/polkit-mate-authentication-agent-1", // Fedora path
"/usr/lib/mate-polkit/polkit-mate-authentication-agent-1",
"/usr/libexec/mate-polkit/polkit-mate-authentication-agent-1",
"/usr/lib/polkit-mate/polkit-mate-authentication-agent-1",
"/usr/lib/x86_64-linux-gnu/mate-polkit/polkit-mate-authentication-agent-1",
}
for _, path := range matePaths {
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Found mate-polkit agent at: %s", path))
return path, nil
}
}
// Fallback to other polkit agents if mate-polkit is not found
fallbackPaths := []string{
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
"/usr/libexec/polkit-gnome-authentication-agent-1",
}
for _, path := range fallbackPaths {
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Found fallback polkit agent at: %s", path))
return path, nil
}
}
return "", fmt.Errorf("no polkit agent found in common locations")
}
// mergeNiriOutputSections extracts output sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match output sections (including commented ones)
outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
// Find all output sections in the existing config
existingOutputs := outputRegex.FindAllString(existingConfig, -1)
if len(existingOutputs) == 0 {
// No output sections to merge
return newConfig, nil
}
// Remove the example output section from the new config
exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "")
// Find where to insert the output sections (after the input section)
inputEndRegex := regexp.MustCompile(`(?m)^}$`)
inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1)
if len(inputMatches) < 1 {
return "", fmt.Errorf("could not find insertion point for output sections")
}
// Insert after the first closing brace (end of input section)
insertPos := inputMatches[0][1]
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("\n// Outputs from existing configuration\n")
for _, output := range existingOutputs {
builder.WriteString(output)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}
// deployHyprlandConfig handles Hyprland configuration deployment with backup and merging
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Hyprland",
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration")
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
// Detect polkit agent path
polkitPath, err := cd.detectPolkitAgent()
if err != nil {
cd.log(fmt.Sprintf("Warning: Could not detect polkit agent: %v", err))
polkitPath = "/usr/lib/mate-polkit/polkit-mate-authentication-agent-1" // fallback
}
// Determine terminal command based on choice
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty" // fallback to ghostty
}
newConfig := strings.ReplaceAll(HyprlandConfig, "{{POLKIT_AGENT_PATH}}", polkitPath)
newConfig = strings.ReplaceAll(newConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
// If there was an existing config, merge the monitor sections
if existingConfig != "" {
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
} else {
newConfig = mergedConfig
cd.log("Successfully merged existing monitor sections")
}
}
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Hyprland configuration")
return result, nil
}
// mergeHyprlandMonitorSections extracts monitor sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match monitor lines (including commented ones)
// Matches: monitor = NAME, RESOLUTION, POSITION, SCALE, etc.
// Also matches commented versions: # monitor = ...
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
// Find all monitor lines in the existing config
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
if len(existingMonitors) == 0 {
// No monitor sections to merge
return newConfig, nil
}
// Remove the example monitor line from the new config
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
// Find where to insert the monitor sections (after the MONITOR CONFIG header)
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
if headerMatch == nil {
return "", fmt.Errorf("could not find MONITOR CONFIG section")
}
// Insert after the header
insertPos := headerMatch[1] + 1 // +1 for the newline
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("# Monitors from existing configuration\n")
for _, monitor := range existingMonitors {
builder.WriteString(monitor)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}
// deployZshrcConfig handles .zshrc configuration deployment with backup
func (cd *ConfigDeployer) deployZshrcConfig() (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: ".zshrc",
Path: filepath.Join(os.Getenv("HOME"), ".zshrc"),
}
// Check if .zshrc already exists
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing .zshrc configuration")
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing .zshrc: %w", err)
return result, result.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing .zshrc to %s", result.BackupPath))
}
// Write the new .zshrc configuration
if err := os.WriteFile(result.Path, []byte(ZshrcConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write .zshrc: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed .zshrc configuration")
return result, nil
}

View File

@@ -0,0 +1,660 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/AvengeMedia/danklinux/internal/deps"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectPolkitAgent(t *testing.T) {
cd := &ConfigDeployer{}
// This test depends on the system having a polkit agent installed
// We'll just test that the function doesn't crash and returns some path or error
path, err := cd.detectPolkitAgent()
if err != nil {
// If no polkit agent is found, that's okay for testing
assert.Contains(t, err.Error(), "no polkit agent found")
} else {
// If found, it should be a valid path
assert.NotEmpty(t, path)
assert.True(t, strings.Contains(path, "polkit"))
}
}
func TestMergeNiriOutputSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
}{
{
name: "no existing outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{"gaps 5"}, // Should keep new config
},
{
name: "merge single output",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`output "eDP-1"`, // Existing output merged
"1920x1080@60.000000", // Existing output details
"Outputs from existing configuration", // Comment added
},
},
{
name: "merge multiple outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
/-output "HDMI-1" {
mode "1920x1080@60.000000"
position x=1920 y=0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`output "eDP-1"`, // First existing output
`/-output "HDMI-1"`, // Second existing output (commented)
"1920x1080@60.000000", // Output details
},
},
{
name: "merge commented outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`/-output "eDP-1"`, // Commented output preserved
"1920x1080@60.000000", // Output details
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
assert.NotContains(t, result, `/-output "eDP-2"`, "example output should be removed")
})
}
}
func TestConfigDeploymentFlow(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy ghostty config to empty directory", func(t *testing.T) {
results, err := cd.deployGhosttyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Ghostty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.Empty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "window-decoration = false")
colorResult := results[1]
assert.Equal(t, "Ghostty Colors", colorResult.ConfigType)
assert.True(t, colorResult.Deployed)
assert.FileExists(t, colorResult.Path)
colorContent, err := os.ReadFile(colorResult.Path)
require.NoError(t, err)
assert.Contains(t, string(colorContent), "background = #101418")
})
t.Run("deploy ghostty config with existing file", func(t *testing.T) {
existingContent := "# Old config\nfont-size = 14\n"
ghosttyPath := getGhosttyPath()
err := os.MkdirAll(filepath.Dir(ghosttyPath), 0755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployGhosttyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Ghostty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.NotEmpty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.Path)
assert.FileExists(t, mainResult.BackupPath)
backupContent, err := os.ReadFile(mainResult.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.NotContains(t, string(newContent), "# Old config")
colorResult := results[1]
assert.Equal(t, "Ghostty Colors", colorResult.ConfigType)
assert.True(t, colorResult.Deployed)
assert.FileExists(t, colorResult.Path)
})
}
func getGhosttyPath() string {
return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config")
}
func TestPolkitPathInjection(t *testing.T) {
testConfig := `spawn-at-startup "{{POLKIT_AGENT_PATH}}"
other content`
result := strings.Replace(testConfig, "{{POLKIT_AGENT_PATH}}", "/test/polkit/path", 1)
assert.Contains(t, result, `spawn-at-startup "/test/polkit/path"`)
assert.NotContains(t, result, "{{POLKIT_AGENT_PATH}}")
}
func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
wantNotContains []string
}{
{
name: "no existing monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================
env = XDG_CURRENT_DESKTOP,niri`,
existingConfig: `# Some other config
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
},
{
name: "merge single monitor",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `# My config
monitor = DP-1, 1920x1080@144, 0x0, 1
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{
"MONITOR CONFIG",
"monitor = DP-1, 1920x1080@144, 0x0, 1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "merge multiple monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`,
wantError: false,
wantContains: []string{
"monitor = DP-1",
"# monitor = HDMI-A-1", // Commented monitor preserved
"monitor = eDP-1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "preserve commented monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================`,
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`,
wantError: false,
wantContains: []string{
"# monitor = DP-1",
"# monitor = HDMI-A-1",
"Monitors from existing configuration",
},
},
{
name: "no monitor config section",
newConfig: `# Some config without monitor section
input {
kb_layout = us
}`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
for _, notWant := range tt.wantNotContains {
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
}
})
}
}
func TestHyprlandConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty)
require.NoError(t, err)
assert.Equal(t, "Hyprland", result.ConfigType)
assert.True(t, result.Deployed)
assert.Empty(t, result.BackupPath)
assert.FileExists(t, result.Path)
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty")
assert.Contains(t, string(content), "exec-once = ")
})
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
existingContent := `# My existing Hyprland config
monitor = DP-1, 1920x1080@144, 0x0, 1
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
general {
gaps_in = 10
}
`
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0755)
require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0644)
require.NoError(t, err)
result, err := cd.deployHyprlandConfig(deps.TerminalKitty)
require.NoError(t, err)
assert.Equal(t, "Hyprland", result.ConfigType)
assert.True(t, result.Deployed)
assert.NotEmpty(t, result.BackupPath)
assert.FileExists(t, result.Path)
assert.FileExists(t, result.BackupPath)
backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty")
assert.NotContains(t, string(newContent), "monitor = eDP-2")
})
}
func TestNiriConfigStructure(t *testing.T) {
assert.Contains(t, NiriConfig, "input {")
assert.Contains(t, NiriConfig, "layout {")
assert.Contains(t, NiriConfig, "binds {")
assert.Contains(t, NiriConfig, "{{POLKIT_AGENT_PATH}}")
assert.Contains(t, NiriConfig, `spawn "{{TERMINAL_COMMAND}}"`)
}
func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# ENVIRONMENT VARS")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
assert.Contains(t, HyprlandConfig, "{{POLKIT_AGENT_PATH}}")
assert.Contains(t, HyprlandConfig, "{{TERMINAL_COMMAND}}")
assert.Contains(t, HyprlandConfig, "exec-once = dms run")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec,")
assert.Contains(t, HyprlandConfig, "bind = $mod, space, exec, dms ipc call spotlight toggle")
assert.Contains(t, HyprlandConfig, "windowrulev2 = noborder, class:^(com\\.mitchellh\\.ghostty)$")
}
func TestGhosttyConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyConfig, "window-decoration = false")
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
assert.Contains(t, GhosttyConfig, "config-file = ./config-dankcolors")
}
func TestGhosttyColorConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyColorConfig, "background = #101418")
assert.Contains(t, GhosttyColorConfig, "foreground = #e0e2e8")
assert.Contains(t, GhosttyColorConfig, "cursor-color = #9dcbfb")
assert.Contains(t, GhosttyColorConfig, "palette = 0=#101418")
assert.Contains(t, GhosttyColorConfig, "palette = 15=#ffffff")
}
func TestKittyConfigStructure(t *testing.T) {
assert.Contains(t, KittyConfig, "font_size 12.0")
assert.Contains(t, KittyConfig, "window_padding_width 12")
assert.Contains(t, KittyConfig, "background_opacity 1.0")
assert.Contains(t, KittyConfig, "include dank-tabs.conf")
assert.Contains(t, KittyConfig, "include dank-theme.conf")
}
func TestKittyThemeConfigStructure(t *testing.T) {
assert.Contains(t, KittyThemeConfig, "foreground #e0e2e8")
assert.Contains(t, KittyThemeConfig, "background #101418")
assert.Contains(t, KittyThemeConfig, "cursor #e0e2e8")
assert.Contains(t, KittyThemeConfig, "color0 #101418")
assert.Contains(t, KittyThemeConfig, "color15 #ffffff")
}
func TestKittyTabsConfigStructure(t *testing.T) {
assert.Contains(t, KittyTabsConfig, "tab_bar_style powerline")
assert.Contains(t, KittyTabsConfig, "tab_powerline_style slanted")
assert.Contains(t, KittyTabsConfig, "active_tab_background #124a73")
assert.Contains(t, KittyTabsConfig, "inactive_tab_background #101418")
}
func TestAlacrittyConfigStructure(t *testing.T) {
assert.Contains(t, AlacrittyConfig, "[general]")
assert.Contains(t, AlacrittyConfig, "~/.config/alacritty/dank-theme.toml")
assert.Contains(t, AlacrittyConfig, "[window]")
assert.Contains(t, AlacrittyConfig, "decorations = \"None\"")
assert.Contains(t, AlacrittyConfig, "padding = { x = 12, y = 12 }")
assert.Contains(t, AlacrittyConfig, "[cursor]")
assert.Contains(t, AlacrittyConfig, "[keyboard]")
}
func TestAlacrittyThemeConfigStructure(t *testing.T) {
assert.Contains(t, AlacrittyThemeConfig, "[colors.primary]")
assert.Contains(t, AlacrittyThemeConfig, "background = '#101418'")
assert.Contains(t, AlacrittyThemeConfig, "foreground = '#e0e2e8'")
assert.Contains(t, AlacrittyThemeConfig, "[colors.cursor]")
assert.Contains(t, AlacrittyThemeConfig, "cursor = '#9dcbfb'")
assert.Contains(t, AlacrittyThemeConfig, "[colors.normal]")
assert.Contains(t, AlacrittyThemeConfig, "[colors.bright]")
}
func TestKittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-kitty-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy kitty config to empty directory", func(t *testing.T) {
results, err := cd.deployKittyConfig()
require.NoError(t, err)
require.Len(t, results, 3)
mainResult := results[0]
assert.Equal(t, "Kitty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "include dank-theme.conf")
themeResult := results[1]
assert.Equal(t, "Kitty Theme", themeResult.ConfigType)
assert.True(t, themeResult.Deployed)
assert.FileExists(t, themeResult.Path)
tabsResult := results[2]
assert.Equal(t, "Kitty Tabs", tabsResult.ConfigType)
assert.True(t, tabsResult.Deployed)
assert.FileExists(t, tabsResult.Path)
})
}
func TestAlacrittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
t.Run("deploy alacritty config to empty directory", func(t *testing.T) {
results, err := cd.deployAlacrittyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Alacritty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "~/.config/alacritty/dank-theme.toml")
assert.Contains(t, string(content), "[window]")
themeResult := results[1]
assert.Equal(t, "Alacritty Theme", themeResult.ConfigType)
assert.True(t, themeResult.Deployed)
assert.FileExists(t, themeResult.Path)
themeContent, err := os.ReadFile(themeResult.Path)
require.NoError(t, err)
assert.Contains(t, string(themeContent), "[colors.primary]")
assert.Contains(t, string(themeContent), "background = '#101418'")
})
t.Run("deploy alacritty config with existing file", func(t *testing.T) {
existingContent := "# Old alacritty config\n[window]\nopacity = 0.9\n"
alacrittyPath := filepath.Join(tempDir, ".config", "alacritty", "alacritty.toml")
err := os.MkdirAll(filepath.Dir(alacrittyPath), 0755)
require.NoError(t, err)
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployAlacrittyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.True(t, mainResult.Deployed)
assert.NotEmpty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.BackupPath)
backupContent, err := os.ReadFile(mainResult.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.NotContains(t, string(newContent), "# Old alacritty config")
assert.Contains(t, string(newContent), "decorations = \"None\"")
})
}

46
internal/config/dms.go Normal file
View File

@@ -0,0 +1,46 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// LocateDMSConfig searches for DMS installation following XDG Base Directory specification
func LocateDMSConfig() (string, error) {
var searchPaths []string
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
if homeDir, err := os.UserHomeDir(); err == nil {
configHome = filepath.Join(homeDir, ".config")
}
}
if configHome != "" {
searchPaths = append(searchPaths, filepath.Join(configHome, "quickshell", "dms"))
}
searchPaths = append(searchPaths, "/usr/share/quickshell/dms")
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs == "" {
configDirs = "/etc/xdg"
}
for _, dir := range strings.Split(configDirs, ":") {
if dir != "" {
searchPaths = append(searchPaths, filepath.Join(dir, "quickshell", "dms"))
}
}
for _, path := range searchPaths {
shellPath := filepath.Join(path, "shell.qml")
if info, err := os.Stat(shellPath); err == nil && !info.IsDir() {
return path, nil
}
}
return "", fmt.Errorf("could not find DMS config (shell.qml) in any valid config path")
}

View File

@@ -0,0 +1,66 @@
fastfetch
# Enable Powerlevel10k instant prompt. Should stay close to the top of ~/.zshrc.
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi
# Default exports
export PATH="$PATH:/usr/bin/alacritty"
export SDL_VIDEODRIVER=wayland
export PATH="$HOME/.local/bin:$PATH"
export PATH="$PATH:/opt/nvim-linux64/bin"
fpath+=(${ZDOTDIR:-$HOME}/.zsh_functions)
export EDITOR=nvim
export PATH="$HOME/.cargo/bin:$PATH"
HISTFILE=$HOME/.zhistory
SAVEHIST=1000
HISTSIZE=999
setopt share_history
setopt hist_ignore_dups
setopt hist_verify
# Tmux on by default
if command -v tmux &> /dev/null && [ -n "$PS1" ] && [[ ! "$TERM" =~ screen ]] && [[ ! "$TERM" =~ tmux ]] && [ -z "$TMUX" ]; then
exec tmux
fi
# Completion using arrow keys (based on history)
bindkey '^[[A' history-search-backward
zstyle ':completion:*' completer _expand _complete _ignored _correct _approximate
zstyle ':completion:*' max-errors 5
zstyle :compinstall filename '$HOME/.zshrc'
autoload -Uz compinit
compinit
# To customize prompt, run `p10k configure` or edit ~/.p10k.zsh.
[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh
# Set aliases
eval "$(zoxide init zsh)"
alias vim="nvim"
alias cd="z"
alias ls="eza --icons=always"
alias sudo="doas"
# Enable and set vim keybindings
set -o vi
bindkey -M viins 'jk' vi-cmd-mode
bindkey -M vicmd '^R' history-incremental-search-backward # Ctrl+R for reverse search
bindkey -M vicmd '/' history-incremental-search-backward # / for history search
bindkey -M vicmd '?' history-incremental-search-forward # ? for forward search
bindkey -M viins '^U' backward-kill-line # Ctrl+U to clear line in insert mode
bindkey -M vicmd '^U' backward-kill-line # Ctrl+U to clear line in command mode
bindkey -M vicmd '^A' beginning-of-line # Ctrl+A to go to the beginning of the line in command mode
bindkey -M vicmd '^E' end-of-line # Ctrl+E to go to the end of the line in command mode
#source plugins
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh
source ~/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
source /usr/share/zsh-theme-powerlevel10k/powerlevel10k.zsh-theme

View File

@@ -0,0 +1,31 @@
[colors.primary]
background = '#101418'
foreground = '#e0e2e8'
[colors.selection]
text = '#e0e2e8'
background = '#124a73'
[colors.cursor]
text = '#101418'
cursor = '#9dcbfb'
[colors.normal]
black = '#101418'
red = '#d75a59'
green = '#8ed88c'
yellow = '#e0d99d'
blue = '#4087bc'
magenta = '#839fbc'
cyan = '#9dcbfb'
white = '#abb2bf'
[colors.bright]
black = '#5c6370'
red = '#e57e7e'
green = '#a2e5a0'
yellow = '#efe9b3'
blue = '#a7d9ff'
magenta = '#3d8197'
cyan = '#5c7ba3'
white = '#ffffff'

View File

@@ -0,0 +1,37 @@
[general]
import = [
"~/.config/alacritty/dank-theme.toml"
]
[window]
decorations = "None"
padding = { x = 12, y = 12 }
opacity = 1.0
[scrolling]
history = 3023
[cursor]
style = { shape = "Block", blinking = "On" }
blink_interval = 500
unfocused_hollow = true
[mouse]
hide_when_typing = true
[selection]
save_to_clipboard = false
[bell]
duration = 0
[keyboard]
bindings = [
{ key = "C", mods = "Control|Shift", action = "Copy" },
{ key = "V", mods = "Control|Shift", action = "Paste" },
{ key = "N", mods = "Control|Shift", action = "SpawnNewInstance" },
{ key = "Equals", mods = "Control|Shift", action = "IncreaseFontSize" },
{ key = "Minus", mods = "Control", action = "DecreaseFontSize" },
{ key = "Key0", mods = "Control", action = "ResetFontSize" },
{ key = "Enter", mods = "Shift", chars = "\n" },
]

View File

@@ -0,0 +1,21 @@
background = #101418
foreground = #e0e2e8
cursor-color = #9dcbfb
selection-background = #124a73
selection-foreground = #e0e2e8
palette = 0=#101418
palette = 1=#d75a59
palette = 2=#8ed88c
palette = 3=#e0d99d
palette = 4=#4087bc
palette = 5=#839fbc
palette = 6=#9dcbfb
palette = 7=#abb2bf
palette = 8=#5c6370
palette = 9=#e57e7e
palette = 10=#a2e5a0
palette = 11=#efe9b3
palette = 12=#a7d9ff
palette = 13=#3d8197
palette = 14=#5c7ba3
palette = 15=#ffffff

View File

@@ -0,0 +1,51 @@
# Font Configuration
font-size = 12
# Window Configuration
window-decoration = false
window-padding-x = 12
window-padding-y = 12
background-opacity = 1.0
background-blur-radius = 32
# Cursor Configuration
cursor-style = block
cursor-style-blink = true
# Scrollback
scrollback-limit = 3023
# Terminal features
mouse-hide-while-typing = true
copy-on-select = false
confirm-close-surface = false
# Disable annoying copied to clipboard
app-notifications = no-clipboard-copy,no-config-reload
# Key bindings for common actions
#keybind = ctrl+c=copy_to_clipboard
#keybind = ctrl+v=paste_from_clipboard
keybind = ctrl+shift+n=new_window
keybind = ctrl+t=new_tab
keybind = ctrl+plus=increase_font_size:1
keybind = ctrl+minus=decrease_font_size:1
keybind = ctrl+zero=reset_font_size
# Material 3 UI elements
unfocused-split-opacity = 0.7
unfocused-split-fill = #44464f
# Tab configuration
gtk-titlebar = false
# Shell integration
shell-integration = detect
shell-integration-features = cursor,sudo,title,no-cursor
keybind = shift+enter=text:\n
# Rando stuff
gtk-single-instance = true
# Dank color generation
config-file = ./config-dankcolors

View File

@@ -0,0 +1,290 @@
# Hyprland Configuration
# https://wiki.hypr.land/Configuring/
# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# ENVIRONMENT VARS
# ==================
env = QT_QPA_PLATFORM,wayland
env = ELECTRON_OZONE_PLATFORM_HINT,auto
env = QT_QPA_PLATFORMTHEME,gtk3
env = QT_QPA_PLATFORMTHEME_QT6,gtk3
env = TERMINAL,{{TERMINAL_COMMAND}}
# ==================
# STARTUP APPS
# ==================
exec-once = bash -c "wl-paste --watch cliphist store &"
exec-once = dms run
exec-once = {{POLKIT_AGENT_PATH}}
# ==================
# INPUT CONFIG
# ==================
input {
kb_layout = us
numlock_by_default = true
}
# ==================
# GENERAL LAYOUT
# ==================
general {
gaps_in = 5
gaps_out = 5
border_size = 0 # off in niri
col.active_border = rgba(707070ff)
col.inactive_border = rgba(d0d0d0ff)
layout = dwindle
}
# ==================
# DECORATION
# ==================
decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 0.9
shadow {
enabled = true
range = 30
render_power = 5
offset = 0 5
color = rgba(00000070)
}
}
# ==================
# ANIMATIONS
# ==================
animations {
enabled = true
animation = windowsIn, 1, 3, default
animation = windowsOut, 1, 3, default
animation = workspaces, 1, 5, default
animation = windowsMove, 1, 4, default
animation = fade, 1, 3, default
animation = border, 1, 3, default
}
# ==================
# LAYOUTS
# ==================
dwindle {
preserve_split = true
}
master {
mfact = 0.5
}
# ==================
# MISC
# ==================
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
vrr = 1
}
# ==================
# WINDOW RULES
# ==================
windowrulev2 = tile, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = rounding 12, class:^(org\.gnome\.)
windowrulev2 = noborder, class:^(org\.gnome\.)
windowrulev2 = tile, class:^(gnome-control-center)$
windowrulev2 = tile, class:^(pavucontrol)$
windowrulev2 = tile, class:^(nm-connection-editor)$
windowrulev2 = float, class:^(gnome-calculator)$
windowrulev2 = float, class:^(galculator)$
windowrulev2 = float, class:^(blueman-manager)$
windowrulev2 = float, class:^(org\.gnome\.Nautilus)$
windowrulev2 = float, class:^(steam)$
windowrulev2 = float, class:^(xdg-desktop-portal)$
windowrulev2 = noborder, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = noborder, class:^(Alacritty)$
windowrulev2 = noborder, class:^(zen)$
windowrulev2 = noborder, class:^(com\.mitchellh\.ghostty)$
windowrulev2 = noborder, class:^(kitty)$
windowrulev2 = float, class:^(firefox)$, title:^(Picture-in-Picture)$
windowrulev2 = float, class:^(zoom)$
windowrulev2 = opacity 0.9 0.9, floating:0, focus:0
layerrule = noanim, ^(quickshell)$
# ==================
# KEYBINDINGS
# ==================
$mod = SUPER
# === Application Launchers ===
bind = $mod, T, exec, {{TERMINAL_COMMAND}}
bind = $mod, space, exec, dms ipc call spotlight toggle
bind = $mod, V, exec, dms ipc call clipboard toggle
bind = $mod, M, exec, dms ipc call processlist toggle
bind = $mod, comma, exec, dms ipc call settings toggle
bind = $mod, N, exec, dms ipc call notifications toggle
bind = $mod SHIFT, N, exec, dms ipc call notepad toggle
bind = $mod, Y, exec, dms ipc call dankdash wallpaper
bind = $mod, TAB, exec, dms ipc call hypr toggleOverview
# === Cheat sheet
bind = $mod SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = $mod ALT, L, exec, dms ipc call lock lock
bind = $mod SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist toggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = $mod, Q, killactive
bind = $mod, F, fullscreen, 1
bind = $mod SHIFT, F, fullscreen, 0
bind = $mod SHIFT, T, togglefloating
bind = $mod, W, togglegroup
# === Focus Navigation ===
bind = $mod, left, movefocus, l
bind = $mod, down, movefocus, d
bind = $mod, up, movefocus, u
bind = $mod, right, movefocus, r
bind = $mod, H, movefocus, l
bind = $mod, J, movefocus, d
bind = $mod, K, movefocus, u
bind = $mod, L, movefocus, r
# === Window Movement ===
bind = $mod SHIFT, left, movewindow, l
bind = $mod SHIFT, down, movewindow, d
bind = $mod SHIFT, up, movewindow, u
bind = $mod SHIFT, right, movewindow, r
bind = $mod SHIFT, H, movewindow, l
bind = $mod SHIFT, J, movewindow, d
bind = $mod SHIFT, K, movewindow, u
bind = $mod SHIFT, L, movewindow, r
# === Column Navigation ===
bind = $mod, Home, focuswindow, first
bind = $mod, End, focuswindow, last
# === Monitor Navigation ===
bind = $mod CTRL, left, focusmonitor, l
bind = $mod CTRL, right, focusmonitor, r
bind = $mod CTRL, H, focusmonitor, l
bind = $mod CTRL, J, focusmonitor, d
bind = $mod CTRL, K, focusmonitor, u
bind = $mod CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = $mod SHIFT CTRL, left, movewindow, mon:l
bind = $mod SHIFT CTRL, down, movewindow, mon:d
bind = $mod SHIFT CTRL, up, movewindow, mon:u
bind = $mod SHIFT CTRL, right, movewindow, mon:r
bind = $mod SHIFT CTRL, H, movewindow, mon:l
bind = $mod SHIFT CTRL, J, movewindow, mon:d
bind = $mod SHIFT CTRL, K, movewindow, mon:u
bind = $mod SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = $mod, Page_Down, workspace, e+1
bind = $mod, Page_Up, workspace, e-1
bind = $mod, U, workspace, e+1
bind = $mod, I, workspace, e-1
bind = $mod CTRL, down, movetoworkspace, e+1
bind = $mod CTRL, up, movetoworkspace, e-1
bind = $mod CTRL, U, movetoworkspace, e+1
bind = $mod CTRL, I, movetoworkspace, e-1
# === Move Workspaces ===
bind = $mod SHIFT, Page_Down, movetoworkspace, e+1
bind = $mod SHIFT, Page_Up, movetoworkspace, e-1
bind = $mod SHIFT, U, movetoworkspace, e+1
bind = $mod SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = $mod, mouse_down, workspace, e+1
bind = $mod, mouse_up, workspace, e-1
bind = $mod CTRL, mouse_down, movetoworkspace, e+1
bind = $mod CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = $mod, 1, workspace, 1
bind = $mod, 2, workspace, 2
bind = $mod, 3, workspace, 3
bind = $mod, 4, workspace, 4
bind = $mod, 5, workspace, 5
bind = $mod, 6, workspace, 6
bind = $mod, 7, workspace, 7
bind = $mod, 8, workspace, 8
bind = $mod, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = $mod SHIFT, 1, movetoworkspace, 1
bind = $mod SHIFT, 2, movetoworkspace, 2
bind = $mod SHIFT, 3, movetoworkspace, 3
bind = $mod SHIFT, 4, movetoworkspace, 4
bind = $mod SHIFT, 5, movetoworkspace, 5
bind = $mod SHIFT, 6, movetoworkspace, 6
bind = $mod SHIFT, 7, movetoworkspace, 7
bind = $mod SHIFT, 8, movetoworkspace, 8
bind = $mod SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = $mod, bracketleft, layoutmsg, preselect l
bind = $mod, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = $mod, R, layoutmsg, togglesplit
bind = $mod CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = $mod, mouse:272, Move window, movewindow
bindmd = $mod, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = $mod, code:20, Expand window left, resizeactive, -100 0
bindd = $mod, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = $mod, minus, resizeactive, -10% 0
binde = $mod, equal, resizeactive, 10% 0
binde = $mod SHIFT, minus, resizeactive, 0 -10%
binde = $mod SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , XF86Launch1, exec, grimblast copy area
bind = CTRL, XF86Launch1, exec, grimblast copy screen
bind = ALT, XF86Launch1, exec, grimblast copy active
bind = , Print, exec, grimblast copy area
bind = CTRL, Print, exec, grimblast copy screen
bind = ALT, Print, exec, grimblast copy active
# === System Controls ===
bind = $mod SHIFT, P, dpms, off

View File

@@ -0,0 +1,24 @@
tab_bar_edge top
tab_bar_style powerline
tab_powerline_style slanted
tab_bar_align left
tab_bar_min_tabs 2
tab_bar_margin_width 0.0
tab_bar_margin_height 2.5 1.5
tab_bar_margin_color #101418
tab_bar_background #101418
active_tab_foreground #cfe5ff
active_tab_background #124a73
active_tab_font_style bold
inactive_tab_foreground #c2c7cf
inactive_tab_background #101418
inactive_tab_font_style normal
tab_activity_symbol " ● "
tab_numbers_style 1
tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]"
active_tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]"

View File

@@ -0,0 +1,24 @@
cursor #e0e2e8
cursor_text_color #c2c7cf
foreground #e0e2e8
background #101418
selection_foreground #243240
selection_background #b9c8da
url_color #9dcbfb
color0 #101418
color1 #d75a59
color2 #8ed88c
color3 #e0d99d
color4 #4087bc
color5 #839fbc
color6 #9dcbfb
color7 #abb2bf
color8 #5c6370
color9 #e57e7e
color10 #a2e5a0
color11 #efe9b3
color12 #a7d9ff
color13 #3d8197
color14 #5c7ba3
color15 #ffffff

View File

@@ -0,0 +1,37 @@
# Font Configuration
font_size 12.0
# Window Configuration
window_padding_width 12
background_opacity 1.0
background_blur 32
hide_window_decorations yes
# Cursor Configuration
cursor_shape block
cursor_blink_interval 1
# Scrollback
scrollback_lines 3000
# Terminal features
copy_on_select yes
strip_trailing_spaces smart
# Key bindings for common actions
map ctrl+shift+n new_window
map ctrl+t new_tab
map ctrl+plus change_font_size all +1.0
map ctrl+minus change_font_size all -1.0
map ctrl+0 change_font_size all 0
# Tab configuration
tab_bar_style powerline
tab_bar_align left
# Shell integration
shell_integration enabled
# Dank color generation
include dank-tabs.conf
include dank-theme.conf

View File

@@ -0,0 +1,418 @@
// This config is in the KDL format: https://kdl.dev
// "/-" comments out the following node.
// Check the wiki for a full description of the configuration:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
config-notification {
disable-failed
}
gestures {
hot-corners {
off
}
}
// Input device configuration.
// Find the full list of options on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Input
input {
keyboard {
xkb {
}
numlock
}
touchpad {
}
mouse {
}
trackpoint {
}
}
// You can configure outputs by their name, which you can find
// by running `niri msg outputs` while inside a niri instance.
// The built-in laptop monitor is usually called "eDP-1".
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Outputs
// Remember to uncomment the node by removing "/-"!
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
variable-refresh-rate
}
// Settings that influence how windows are positioned and sized.
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Layout
layout {
// Set gaps around windows in logical pixels.
gaps 5
background-color "transparent"
// When to center a column when changing focus, options are:
// - "never", default behavior, focusing an off-screen column will keep at the left
// or right edge of the screen.
// - "always", the focused column will always be centered.
// - "on-overflow", focusing a column will center it if it doesn't fit
// together with the previously focused column.
center-focused-column "never"
// You can customize the widths that "switch-preset-column-width" (Mod+R) toggles between.
preset-column-widths {
// Proportion sets the width as a fraction of the output width, taking gaps into account.
// For example, you can perfectly fit four windows sized "proportion 0.25" on an output.
// The default preset widths are 1/3, 1/2 and 2/3 of the output.
proportion 0.33333
proportion 0.5
proportion 0.66667
// Fixed sets the width in logical pixels exactly.
// fixed 1920
}
// You can also customize the heights that "switch-preset-window-height" (Mod+Shift+R) toggles between.
// preset-window-heights { }
// You can change the default width of the new windows.
default-column-width { proportion 0.5; }
// If you leave the brackets empty, the windows themselves will decide their initial width.
// default-column-width {}
// By default focus ring and border are rendered as a solid background rectangle
// behind windows. That is, they will show up through semitransparent windows.
// This is because windows using client-side decorations can have an arbitrary shape.
//
// If you don't like that, you should uncomment `prefer-no-csd` below.
// Niri will draw focus ring and border *around* windows that agree to omit their
// client-side decorations.
//
// Alternatively, you can override it with a window rule called
// `draw-border-with-background`.
border {
off
width 4
active-color "#707070" // Neutral gray
inactive-color "#d0d0d0" // Light gray
urgent-color "#cc4444" // Softer red
}
focus-ring {
width 2
active-color "#808080" // Medium gray
inactive-color "#505050" // Dark gray
}
shadow {
softness 30
spread 5
offset x=0 y=5
color "#0007"
}
struts {
}
}
layer-rule {
match namespace="^quickshell$"
place-within-backdrop true
}
overview {
workspace-shadow {
off
}
}
// Add lines like this to spawn processes at startup.
// Note that running niri as a session supports xdg-desktop-autostart,
// which may be more convenient to use.
// See the binds section below for more spawn examples.
// This line starts waybar, a commonly used bar for Wayland compositors.
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
spawn-at-startup "dms" "run"
spawn-at-startup "{{POLKIT_AGENT_PATH}}"
environment {
XDG_CURRENT_DESKTOP "niri"
QT_QPA_PLATFORM "wayland"
ELECTRON_OZONE_PLATFORM_HINT "auto"
QT_QPA_PLATFORMTHEME "gtk3"
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
TERMINAL "{{TERMINAL_COMMAND}}"
}
hotkey-overlay {
skip-at-startup
}
prefer-no-csd
screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png"
animations {
workspace-switch {
spring damping-ratio=0.80 stiffness=523 epsilon=0.0001
}
window-open {
duration-ms 150
curve "ease-out-expo"
}
window-close {
duration-ms 150
curve "ease-out-quad"
}
horizontal-view-movement {
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
}
window-movement {
spring damping-ratio=0.75 stiffness=323 epsilon=0.0001
}
window-resize {
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
}
config-notification-open-close {
spring damping-ratio=0.65 stiffness=923 epsilon=0.001
}
screenshot-ui-open {
duration-ms 200
curve "ease-out-quad"
}
overview-open-close {
spring damping-ratio=0.85 stiffness=800 epsilon=0.0001
}
}
// Window rules let you adjust behavior for individual windows.
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Window-Rules
// Work around WezTerm's initial configure bug
// by setting an empty default-column-width.
window-rule {
// This regular expression is intentionally made as specific as possible,
// since this is the default config, and we want no false positives.
// You can get away with just app-id="wezterm" if you want.
match app-id=r#"^org\.wezfurlong\.wezterm$"#
default-column-width {}
}
window-rule {
match app-id=r#"^org\.gnome\."#
draw-border-with-background false
geometry-corner-radius 12
clip-to-geometry true
}
window-rule {
match app-id=r#"^gnome-control-center$"#
match app-id=r#"^pavucontrol$"#
match app-id=r#"^nm-connection-editor$"#
default-column-width { proportion 0.5; }
open-floating false
}
window-rule {
match app-id=r#"^gnome-calculator$"#
match app-id=r#"^galculator$"#
match app-id=r#"^blueman-manager$"#
match app-id=r#"^org\.gnome\.Nautilus$"#
match app-id=r#"^steam$"#
match app-id=r#"^xdg-desktop-portal$"#
open-floating true
}
window-rule {
match app-id=r#"^org\.wezfurlong\.wezterm$"#
match app-id="Alacritty"
match app-id="zen"
match app-id="com.mitchellh.ghostty"
match app-id="kitty"
draw-border-with-background false
}
window-rule {
match is-active=false
opacity 0.9
}
window-rule {
match app-id=r#"firefox$"# title="^Picture-in-Picture$"
match app-id="zoom"
open-floating true
}
window-rule {
geometry-corner-radius 12
clip-to-geometry true
}
binds {
// === System & Overview ===
Mod+D { spawn "niri" "msg" "action" "toggle-overview"; }
Mod+Tab repeat=false { toggle-overview; }
Mod+Shift+Slash { show-hotkey-overlay; }
// === Application Launchers ===
Mod+T hotkey-overlay-title="Open Terminal" { spawn "{{TERMINAL_COMMAND}}"; }
Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle";
}
Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle";
}
Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "toggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
}
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
// === Security ===
Mod+Alt+L hotkey-overlay-title="Lock Screen" {
spawn "dms" "ipc" "call" "lock" "lock";
}
Mod+Shift+E { quit; }
Ctrl+Alt+Delete hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
// === Audio Controls ===
XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "increment" "3";
}
XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "decrement" "3";
}
XF86AudioMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "mute";
}
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
// === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "increment" "5" "";
}
XF86MonBrightnessDown allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "decrement" "5" "";
}
// === Window Management ===
Mod+Q repeat=false { close-window; }
Mod+F { maximize-column; }
Mod+Shift+F { fullscreen-window; }
Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; }
// === Focus Navigation ===
Mod+Left { focus-column-left; }
Mod+Down { focus-window-down; }
Mod+Up { focus-window-up; }
Mod+Right { focus-column-right; }
Mod+H { focus-column-left; }
Mod+J { focus-window-down; }
Mod+K { focus-window-up; }
Mod+L { focus-column-right; }
// === Window Movement ===
Mod+Shift+Left { move-column-left; }
Mod+Shift+Down { move-window-down; }
Mod+Shift+Up { move-window-up; }
Mod+Shift+Right { move-column-right; }
Mod+Shift+H { move-column-left; }
Mod+Shift+J { move-window-down; }
Mod+Shift+K { move-window-up; }
Mod+Shift+L { move-column-right; }
// === Column Navigation ===
Mod+Home { focus-column-first; }
Mod+End { focus-column-last; }
Mod+Ctrl+Home { move-column-to-first; }
Mod+Ctrl+End { move-column-to-last; }
// === Monitor Navigation ===
Mod+Ctrl+Left { focus-monitor-left; }
//Mod+Ctrl+Down { focus-monitor-down; }
//Mod+Ctrl+Up { focus-monitor-up; }
Mod+Ctrl+Right { focus-monitor-right; }
Mod+Ctrl+H { focus-monitor-left; }
Mod+Ctrl+J { focus-monitor-down; }
Mod+Ctrl+K { focus-monitor-up; }
Mod+Ctrl+L { focus-monitor-right; }
// === Move to Monitor ===
Mod+Shift+Ctrl+Left { move-column-to-monitor-left; }
Mod+Shift+Ctrl+Down { move-column-to-monitor-down; }
Mod+Shift+Ctrl+Up { move-column-to-monitor-up; }
Mod+Shift+Ctrl+Right { move-column-to-monitor-right; }
Mod+Shift+Ctrl+H { move-column-to-monitor-left; }
Mod+Shift+Ctrl+J { move-column-to-monitor-down; }
Mod+Shift+Ctrl+K { move-column-to-monitor-up; }
Mod+Shift+Ctrl+L { move-column-to-monitor-right; }
// === Workspace Navigation ===
Mod+Page_Down { focus-workspace-down; }
Mod+Page_Up { focus-workspace-up; }
Mod+U { focus-workspace-down; }
Mod+I { focus-workspace-up; }
Mod+Ctrl+Down { move-column-to-workspace-down; }
Mod+Ctrl+Up { move-column-to-workspace-up; }
Mod+Ctrl+U { move-column-to-workspace-down; }
Mod+Ctrl+I { move-column-to-workspace-up; }
// === Move Workspaces ===
Mod+Shift+Page_Down { move-workspace-down; }
Mod+Shift+Page_Up { move-workspace-up; }
Mod+Shift+U { move-workspace-down; }
Mod+Shift+I { move-workspace-up; }
// === Mouse Wheel Navigation ===
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
Mod+WheelScrollUp cooldown-ms=150 { focus-workspace-up; }
Mod+Ctrl+WheelScrollDown cooldown-ms=150 { move-column-to-workspace-down; }
Mod+Ctrl+WheelScrollUp cooldown-ms=150 { move-column-to-workspace-up; }
Mod+WheelScrollRight { focus-column-right; }
Mod+WheelScrollLeft { focus-column-left; }
Mod+Ctrl+WheelScrollRight { move-column-right; }
Mod+Ctrl+WheelScrollLeft { move-column-left; }
Mod+Shift+WheelScrollDown { focus-column-right; }
Mod+Shift+WheelScrollUp { focus-column-left; }
Mod+Ctrl+Shift+WheelScrollDown { move-column-right; }
Mod+Ctrl+Shift+WheelScrollUp { move-column-left; }
// === Numbered Workspaces ===
Mod+1 { focus-workspace 1; }
Mod+2 { focus-workspace 2; }
Mod+3 { focus-workspace 3; }
Mod+4 { focus-workspace 4; }
Mod+5 { focus-workspace 5; }
Mod+6 { focus-workspace 6; }
Mod+7 { focus-workspace 7; }
Mod+8 { focus-workspace 8; }
Mod+9 { focus-workspace 9; }
// === Move to Numbered Workspaces ===
Mod+Shift+1 { move-column-to-workspace 1; }
Mod+Shift+2 { move-column-to-workspace 2; }
Mod+Shift+3 { move-column-to-workspace 3; }
Mod+Shift+4 { move-column-to-workspace 4; }
Mod+Shift+5 { move-column-to-workspace 5; }
Mod+Shift+6 { move-column-to-workspace 6; }
Mod+Shift+7 { move-column-to-workspace 7; }
Mod+Shift+8 { move-column-to-workspace 8; }
Mod+Shift+9 { move-column-to-workspace 9; }
// === Column Management ===
Mod+BracketLeft { consume-or-expel-window-left; }
Mod+BracketRight { consume-or-expel-window-right; }
Mod+Period { expel-window-from-column; }
// === Sizing & Layout ===
Mod+R { switch-preset-column-width; }
Mod+Shift+R { switch-preset-window-height; }
Mod+Ctrl+R { reset-window-height; }
Mod+Ctrl+F { expand-column-to-available-width; }
Mod+C { center-column; }
Mod+Ctrl+C { center-visible-columns; }
// === Manual Sizing ===
Mod+Minus { set-column-width "-10%"; }
Mod+Equal { set-column-width "+10%"; }
Mod+Shift+Minus { set-window-height "-10%"; }
Mod+Shift+Equal { set-window-height "+10%"; }
// === Screenshots ===
XF86Launch1 { screenshot; }
Ctrl+XF86Launch1 { screenshot-screen; }
Alt+XF86Launch1 { screenshot-window; }
Print { screenshot; }
Ctrl+Print { screenshot-screen; }
Alt+Print { screenshot-window; }
// === System Controls ===
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
Mod+Shift+P { power-off-monitors; }
}
debug {
honor-xdg-activation-with-invalid-serial
}

View File

@@ -0,0 +1,6 @@
package config
import _ "embed"
//go:embed embedded/hyprland.conf
var HyprlandConfig string

6
internal/config/niri.go Normal file
View File

@@ -0,0 +1,6 @@
package config
import _ "embed"
//go:embed embedded/niri.kdl
var NiriConfig string

View File

@@ -0,0 +1,6 @@
package config
import _ "embed"
//go:embed embedded/.zshrc
var ZshrcConfig string

View File

@@ -0,0 +1,24 @@
package config
import _ "embed"
//go:embed embedded/ghostty.conf
var GhosttyConfig string
//go:embed embedded/ghostty-colors.conf
var GhosttyColorConfig string
//go:embed embedded/kitty.conf
var KittyConfig string
//go:embed embedded/kitty-theme.conf
var KittyThemeConfig string
//go:embed embedded/kitty-tabs.conf
var KittyTabsConfig string
//go:embed embedded/alacritty.toml
var AlacrittyConfig string
//go:embed embedded/alacritty-theme.toml
var AlacrittyThemeConfig string

453
internal/dank16/dank16.go Normal file
View File

@@ -0,0 +1,453 @@
package dank16
import (
"fmt"
"math"
"github.com/lucasb-eyer/go-colorful"
)
type RGB struct {
R, G, B float64
}
type HSV struct {
H, S, V float64
}
func HexToRGB(hex string) RGB {
if hex[0] == '#' {
hex = hex[1:]
}
var r, g, b uint8
fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b)
return RGB{
R: float64(r) / 255.0,
G: float64(g) / 255.0,
B: float64(b) / 255.0,
}
}
func RGBToHex(rgb RGB) string {
r := math.Max(0, math.Min(1, rgb.R))
g := math.Max(0, math.Min(1, rgb.G))
b := math.Max(0, math.Min(1, rgb.B))
return fmt.Sprintf("#%02x%02x%02x", int(r*255), int(g*255), int(b*255))
}
func RGBToHSV(rgb RGB) HSV {
max := math.Max(math.Max(rgb.R, rgb.G), rgb.B)
min := math.Min(math.Min(rgb.R, rgb.G), rgb.B)
delta := max - min
var h float64
if delta == 0 {
h = 0
} else if max == rgb.R {
h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0
} else if max == rgb.G {
h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0
} else {
h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0
}
if h < 0 {
h += 1.0
}
var s float64
if max == 0 {
s = 0
} else {
s = delta / max
}
return HSV{H: h, S: s, V: max}
}
func HSVToRGB(hsv HSV) RGB {
h := hsv.H * 6.0
c := hsv.V * hsv.S
x := c * (1.0 - math.Abs(math.Mod(h, 2.0)-1.0))
m := hsv.V - c
var r, g, b float64
switch int(h) {
case 0:
r, g, b = c, x, 0
case 1:
r, g, b = x, c, 0
case 2:
r, g, b = 0, c, x
case 3:
r, g, b = 0, x, c
case 4:
r, g, b = x, 0, c
case 5:
r, g, b = c, 0, x
default:
r, g, b = c, 0, x
}
return RGB{R: r + m, G: g + m, B: b + m}
}
func sRGBToLinear(c float64) float64 {
if c <= 0.04045 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
func Luminance(hex string) float64 {
rgb := HexToRGB(hex)
return 0.2126*sRGBToLinear(rgb.R) + 0.7152*sRGBToLinear(rgb.G) + 0.0722*sRGBToLinear(rgb.B)
}
func ContrastRatio(hexFg, hexBg string) float64 {
lumFg := Luminance(hexFg)
lumBg := Luminance(hexBg)
lighter := math.Max(lumFg, lumBg)
darker := math.Min(lumFg, lumBg)
return (lighter + 0.05) / (darker + 0.05)
}
func getLstar(hex string) float64 {
rgb := HexToRGB(hex)
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
L, _, _ := col.Lab()
return L * 100.0 // go-colorful uses 0-1, we need 0-100 for DPS
}
// Lab to hex, clamping if needed
func labToHex(L, a, b float64) string {
c := colorful.Lab(L/100.0, a, b) // back to 0-1 for go-colorful
r, g, b2 := c.Clamped().RGB255()
return fmt.Sprintf("#%02x%02x%02x", r, g, b2)
}
// Adjust brightness while keeping the same hue
func retoneToL(hex string, Ltarget float64) string {
rgb := HexToRGB(hex)
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
L, a, b := col.Lab()
L100 := L * 100.0
scale := 1.0
if L100 != 0 {
scale = Ltarget / L100
}
a2, b2 := a*scale, b*scale
// Don't let it get too saturated
maxChroma := 0.4
if math.Hypot(a2, b2) > maxChroma {
k := maxChroma / math.Hypot(a2, b2)
a2 *= k
b2 *= k
}
return labToHex(Ltarget, a2, b2)
}
func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 {
Lf := getLstar(hexFg)
Lb := getLstar(hexBg)
phi := 1.618
inv := 0.618
lc := math.Pow(math.Abs(math.Pow(Lb, phi)-math.Pow(Lf, phi)), inv)*1.414 - 40
if negativePolarity {
lc += 5
}
return lc
}
func DeltaPhiStarContrast(hexFg, hexBg string, isLightMode bool) float64 {
negativePolarity := !isLightMode
return DeltaPhiStar(hexFg, hexBg, negativePolarity)
}
func EnsureContrast(hexColor, hexBg string, minRatio float64, isLightMode bool) string {
currentRatio := ContrastRatio(hexColor, hexBg)
if currentRatio >= minRatio {
return hexColor
}
rgb := HexToRGB(hexColor)
hsv := RGBToHSV(rgb)
for step := 1; step < 30; step++ {
delta := float64(step) * 0.02
if isLightMode {
newV := math.Max(0, hsv.V-delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
newV = math.Min(1, hsv.V+delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
} else {
newV := math.Min(1, hsv.V+delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
newV = math.Max(0, hsv.V-delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
}
}
return hexColor
}
func EnsureContrastDPS(hexColor, hexBg string, minLc float64, isLightMode bool) string {
currentLc := DeltaPhiStarContrast(hexColor, hexBg, isLightMode)
if currentLc >= minLc {
return hexColor
}
rgb := HexToRGB(hexColor)
hsv := RGBToHSV(rgb)
for step := 1; step < 50; step++ {
delta := float64(step) * 0.015
if isLightMode {
newV := math.Max(0, hsv.V-delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
newV = math.Min(1, hsv.V+delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
} else {
newV := math.Min(1, hsv.V+delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
newV = math.Max(0, hsv.V-delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
}
}
return hexColor
}
// Nudge L* until contrast is good enough. Keeps hue intact unlike HSV fiddling.
func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode bool) string {
current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode)
if current >= minLc {
return hexColor
}
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab()
dir := 1.0
if isLightMode {
dir = -1.0 // light mode = darker text
}
step := 0.5
for i := 0; i < 120; i++ {
Lf = math.Max(0, math.Min(100, Lf+dir*step))
cand := labToHex(Lf, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
return cand
}
}
return hexColor
}
type PaletteOptions struct {
IsLight bool
Background string
UseDPS bool
}
func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOptions) string {
if opts.UseDPS {
return EnsureContrastDPSLstar(hexColor, hexBg, target, opts.IsLight)
}
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
if isLight {
containerV := math.Min(hsv.V*1.77, 1.0)
containerS := hsv.S * 0.32
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
containerV := hsv.V * 0.463
containerS := math.Min(hsv.S*1.834, 1.0)
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
func GeneratePalette(primaryColor string, opts PaletteOptions) []string {
baseColor := DeriveContainer(primaryColor, opts.IsLight)
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
palette := make([]string, 0, 16)
var normalTextTarget, secondaryTarget float64
if opts.UseDPS {
normalTextTarget = 40.0
secondaryTarget = 35.0
} else {
normalTextTarget = 4.5
secondaryTarget = 3.0
}
var bgColor string
if opts.Background != "" {
bgColor = opts.Background
} else if opts.IsLight {
bgColor = "#f8f8f8"
} else {
bgColor = "#1a1a1a"
}
palette = append(palette, bgColor)
hueShift := (hsv.H - 0.6) * 0.12
satBoost := 1.15
redH := math.Mod(0.0+hueShift+1.0, 1.0)
var redColor string
if opts.IsLight {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.80*satBoost, 1.0), V: 0.55}))
palette = append(palette, ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
} else {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.65*satBoost, 1.0), V: 0.80}))
palette = append(palette, ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
}
greenH := math.Mod(0.33+hueShift+1.0, 1.0)
var greenColor string
if opts.IsLight {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.9, 0.80)*satBoost, 1.0), V: 0.45}))
palette = append(palette, ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
} else {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.42*satBoost, 1.0), V: 0.84}))
palette = append(palette, ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
}
yellowH := math.Mod(0.15+hueShift+1.0, 1.0)
var yellowColor string
if opts.IsLight {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.75*satBoost, 1.0), V: 0.50}))
palette = append(palette, ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
} else {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.38*satBoost, 1.0), V: 0.86}))
palette = append(palette, ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
}
var blueColor string
if opts.IsLight {
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.9, 0.7), V: hsv.V * 1.1}))
palette = append(palette, ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
} else {
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.8, 0.6), V: math.Min(hsv.V*1.6, 1.0)}))
palette = append(palette, ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
}
magH := hsv.H - 0.03
if magH < 0 {
magH += 1.0
}
var magColor string
hr := HexToRGB(primaryColor)
hh := RGBToHSV(hr)
if opts.IsLight {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Max(hh.S*0.9, 0.7), V: hh.V * 0.85}))
palette = append(palette, ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
} else {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: hh.S * 0.8, V: hh.V * 0.75}))
palette = append(palette, ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
}
cyanH := hsv.H + 0.08
if cyanH > 1.0 {
cyanH -= 1.0
}
palette = append(palette, ensureContrastAuto(primaryColor, bgColor, normalTextTarget, opts))
if opts.IsLight {
palette = append(palette, "#1a1a1a")
palette = append(palette, "#2e2e2e")
} else {
palette = append(palette, "#abb2bf")
palette = append(palette, "#5c6370")
}
if opts.IsLight {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.70*satBoost, 1.0), V: 0.65}))
palette = append(palette, ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.85, 0.75)*satBoost, 1.0), V: 0.55}))
palette = append(palette, ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.68*satBoost, 1.0), V: 0.60}))
palette = append(palette, ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
hr := HexToRGB(primaryColor)
hh := RGBToHSV(hr)
brightBlue := RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Min(hh.S*1.1, 1.0), V: math.Min(hh.V*1.2, 1.0)}))
palette = append(palette, ensureContrastAuto(brightBlue, bgColor, secondaryTarget, opts))
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.9, 0.75), V: math.Min(hsv.V*1.25, 1.0)}))
palette = append(palette, ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
brightCyan := RGBToHex(HSVToRGB(HSV{H: cyanH, S: math.Max(hsv.S*0.75, 0.65), V: math.Min(hsv.V*1.25, 1.0)}))
palette = append(palette, ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
} else {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.50*satBoost, 1.0), V: 0.88}))
palette = append(palette, ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.35*satBoost, 1.0), V: 0.88}))
palette = append(palette, ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.30*satBoost, 1.0), V: 0.91}))
palette = append(palette, ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
// Make it way brighter for type names in dark mode
brightBlue := retoneToL(primaryColor, 85.0)
palette = append(palette, brightBlue)
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.7, 0.6), V: math.Min(hsv.V*1.3, 0.9)}))
palette = append(palette, ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
brightCyanH := hsv.H + 0.02
if brightCyanH > 1.0 {
brightCyanH -= 1.0
}
brightCyan := RGBToHex(HSVToRGB(HSV{H: brightCyanH, S: math.Max(hsv.S*0.6, 0.5), V: math.Min(hsv.V*1.2, 0.85)}))
palette = append(palette, ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
}
if opts.IsLight {
palette = append(palette, "#1a1a1a")
} else {
palette = append(palette, "#ffffff")
}
return palette
}

View File

@@ -0,0 +1,727 @@
package dank16
import (
"encoding/json"
"math"
"testing"
)
func TestHexToRGB(t *testing.T) {
tests := []struct {
name string
input string
expected RGB
}{
{
name: "black with hash",
input: "#000000",
expected: RGB{R: 0.0, G: 0.0, B: 0.0},
},
{
name: "white with hash",
input: "#ffffff",
expected: RGB{R: 1.0, G: 1.0, B: 1.0},
},
{
name: "red without hash",
input: "ff0000",
expected: RGB{R: 1.0, G: 0.0, B: 0.0},
},
{
name: "purple",
input: "#625690",
expected: RGB{R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412},
},
{
name: "mid gray",
input: "#808080",
expected: RGB{R: 0.5019607843137255, G: 0.5019607843137255, B: 0.5019607843137255},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HexToRGB(tt.input)
if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) {
t.Errorf("HexToRGB(%s) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestRGBToHex(t *testing.T) {
tests := []struct {
name string
input RGB
expected string
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: "#000000",
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: "#ffffff",
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: "#ff0000",
},
{
name: "clamping above 1.0",
input: RGB{R: 1.5, G: 0.5, B: 0.5},
expected: "#ff7f7f",
},
{
name: "clamping below 0.0",
input: RGB{R: -0.5, G: 0.5, B: 0.5},
expected: "#007f7f",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHex(tt.input)
if result != tt.expected {
t.Errorf("RGBToHex(%v) = %s, expected %s", tt.input, result, tt.expected)
}
})
}
}
func TestRGBToHSV(t *testing.T) {
tests := []struct {
name string
input RGB
expected HSV
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: HSV{H: 0.0, S: 0.0, V: 0.0},
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: HSV{H: 0.0, S: 0.0, V: 1.0},
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: HSV{H: 0.0, S: 1.0, V: 1.0},
},
{
name: "green",
input: RGB{R: 0.0, G: 1.0, B: 0.0},
expected: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0},
},
{
name: "blue",
input: RGB{R: 0.0, G: 0.0, B: 1.0},
expected: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHSV(tt.input)
if !floatEqual(result.H, tt.expected.H) || !floatEqual(result.S, tt.expected.S) || !floatEqual(result.V, tt.expected.V) {
t.Errorf("RGBToHSV(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestHSVToRGB(t *testing.T) {
tests := []struct {
name string
input HSV
expected RGB
}{
{
name: "black",
input: HSV{H: 0.0, S: 0.0, V: 0.0},
expected: RGB{R: 0.0, G: 0.0, B: 0.0},
},
{
name: "white",
input: HSV{H: 0.0, S: 0.0, V: 1.0},
expected: RGB{R: 1.0, G: 1.0, B: 1.0},
},
{
name: "red",
input: HSV{H: 0.0, S: 1.0, V: 1.0},
expected: RGB{R: 1.0, G: 0.0, B: 0.0},
},
{
name: "green",
input: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0},
expected: RGB{R: 0.0, G: 1.0, B: 0.0},
},
{
name: "blue",
input: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0},
expected: RGB{R: 0.0, G: 0.0, B: 1.0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HSVToRGB(tt.input)
if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) {
t.Errorf("HSVToRGB(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestLuminance(t *testing.T) {
tests := []struct {
name string
input string
expected float64
}{
{
name: "black",
input: "#000000",
expected: 0.0,
},
{
name: "white",
input: "#ffffff",
expected: 1.0,
},
{
name: "red",
input: "#ff0000",
expected: 0.2126,
},
{
name: "green",
input: "#00ff00",
expected: 0.7152,
},
{
name: "blue",
input: "#0000ff",
expected: 0.0722,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Luminance(tt.input)
if !floatEqual(result, tt.expected) {
t.Errorf("Luminance(%s) = %f, expected %f", tt.input, result, tt.expected)
}
})
}
}
func TestContrastRatio(t *testing.T) {
tests := []struct {
name string
fg string
bg string
expected float64
}{
{
name: "black on white",
fg: "#000000",
bg: "#ffffff",
expected: 21.0,
},
{
name: "white on black",
fg: "#ffffff",
bg: "#000000",
expected: 21.0,
},
{
name: "same color",
fg: "#808080",
bg: "#808080",
expected: 1.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ContrastRatio(tt.fg, tt.bg)
if !floatEqual(result, tt.expected) {
t.Errorf("ContrastRatio(%s, %s) = %f, expected %f", tt.fg, tt.bg, result, tt.expected)
}
})
}
}
func TestEnsureContrast(t *testing.T) {
tests := []struct {
name string
color string
bg string
minRatio float64
isLightMode bool
}{
{
name: "already sufficient contrast dark mode",
color: "#ffffff",
bg: "#000000",
minRatio: 4.5,
isLightMode: false,
},
{
name: "already sufficient contrast light mode",
color: "#000000",
bg: "#ffffff",
minRatio: 4.5,
isLightMode: true,
},
{
name: "needs adjustment dark mode",
color: "#404040",
bg: "#1a1a1a",
minRatio: 4.5,
isLightMode: false,
},
{
name: "needs adjustment light mode",
color: "#c0c0c0",
bg: "#f8f8f8",
minRatio: 4.5,
isLightMode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EnsureContrast(tt.color, tt.bg, tt.minRatio, tt.isLightMode)
actualRatio := ContrastRatio(result, tt.bg)
if actualRatio < tt.minRatio {
t.Errorf("EnsureContrast(%s, %s, %f, %t) = %s with ratio %f, expected ratio >= %f",
tt.color, tt.bg, tt.minRatio, tt.isLightMode, result, actualRatio, tt.minRatio)
}
})
}
}
func TestGeneratePalette(t *testing.T) {
tests := []struct {
name string
base string
opts PaletteOptions
}{
{
name: "dark theme default",
base: "#625690",
opts: PaletteOptions{IsLight: false},
},
{
name: "light theme default",
base: "#625690",
opts: PaletteOptions{IsLight: true},
},
{
name: "light theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: true,
Background: "#fafafa",
},
},
{
name: "dark theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: false,
Background: "#0a0a0a",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GeneratePalette(tt.base, tt.opts)
if len(result) != 16 {
t.Errorf("GeneratePalette returned %d colors, expected 16", len(result))
}
for i, color := range result {
if len(color) != 7 || color[0] != '#' {
t.Errorf("Color at index %d (%s) is not a valid hex color", i, color)
}
}
if tt.opts.Background != "" && result[0] != tt.opts.Background {
t.Errorf("Background color = %s, expected %s", result[0], tt.opts.Background)
} else if !tt.opts.IsLight && tt.opts.Background == "" && result[0] != "#1a1a1a" {
t.Errorf("Dark mode background = %s, expected #1a1a1a", result[0])
} else if tt.opts.IsLight && tt.opts.Background == "" && result[0] != "#f8f8f8" {
t.Errorf("Light mode background = %s, expected #f8f8f8", result[0])
}
if tt.opts.IsLight && result[15] != "#1a1a1a" {
t.Errorf("Light mode foreground = %s, expected #1a1a1a", result[15])
} else if !tt.opts.IsLight && result[15] != "#ffffff" {
t.Errorf("Dark mode foreground = %s, expected #ffffff", result[15])
}
})
}
}
func TestEnrichVSCodeTheme(t *testing.T) {
colors := GeneratePalette("#625690", PaletteOptions{IsLight: false})
baseTheme := map[string]interface{}{
"name": "Test Theme",
"type": "dark",
"colors": map[string]interface{}{
"editor.background": "#000000",
},
}
themeJSON, err := json.Marshal(baseTheme)
if err != nil {
t.Fatalf("Failed to marshal base theme: %v", err)
}
result, err := EnrichVSCodeTheme(themeJSON, colors)
if err != nil {
t.Fatalf("EnrichVSCodeTheme failed: %v", err)
}
var enriched map[string]interface{}
if err := json.Unmarshal(result, &enriched); err != nil {
t.Fatalf("Failed to unmarshal result: %v", err)
}
colorsMap, ok := enriched["colors"].(map[string]interface{})
if !ok {
t.Fatal("colors is not a map")
}
terminalColors := []string{
"terminal.ansiBlack",
"terminal.ansiRed",
"terminal.ansiGreen",
"terminal.ansiYellow",
"terminal.ansiBlue",
"terminal.ansiMagenta",
"terminal.ansiCyan",
"terminal.ansiWhite",
"terminal.ansiBrightBlack",
"terminal.ansiBrightRed",
"terminal.ansiBrightGreen",
"terminal.ansiBrightYellow",
"terminal.ansiBrightBlue",
"terminal.ansiBrightMagenta",
"terminal.ansiBrightCyan",
"terminal.ansiBrightWhite",
}
for i, key := range terminalColors {
if val, ok := colorsMap[key]; !ok {
t.Errorf("Missing terminal color: %s", key)
} else if val != colors[i] {
t.Errorf("%s = %s, expected %s", key, val, colors[i])
}
}
if colorsMap["editor.background"] != "#000000" {
t.Error("Original theme colors should be preserved")
}
}
func TestEnrichVSCodeThemeInvalidJSON(t *testing.T) {
colors := GeneratePalette("#625690", PaletteOptions{IsLight: false})
invalidJSON := []byte("{invalid json")
_, err := EnrichVSCodeTheme(invalidJSON, colors)
if err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
func TestRoundTripConversion(t *testing.T) {
testColors := []string{"#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#625690", "#808080"}
for _, hex := range testColors {
t.Run(hex, func(t *testing.T) {
rgb := HexToRGB(hex)
result := RGBToHex(rgb)
if result != hex {
t.Errorf("Round trip %s -> RGB -> %s failed", hex, result)
}
})
}
}
func TestRGBHSVRoundTrip(t *testing.T) {
testCases := []RGB{
{R: 0.0, G: 0.0, B: 0.0},
{R: 1.0, G: 1.0, B: 1.0},
{R: 1.0, G: 0.0, B: 0.0},
{R: 0.0, G: 1.0, B: 0.0},
{R: 0.0, G: 0.0, B: 1.0},
{R: 0.5, G: 0.5, B: 0.5},
{R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412},
}
for _, rgb := range testCases {
t.Run("", func(t *testing.T) {
hsv := RGBToHSV(rgb)
result := HSVToRGB(hsv)
if !floatEqual(result.R, rgb.R) || !floatEqual(result.G, rgb.G) || !floatEqual(result.B, rgb.B) {
t.Errorf("Round trip RGB->HSV->RGB failed: %v -> %v -> %v", rgb, hsv, result)
}
})
}
}
func floatEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func TestDeltaPhiStar(t *testing.T) {
tests := []struct {
name string
fg string
bg string
negativePolarity bool
minExpected float64
}{
{
name: "white on black (negative polarity)",
fg: "#ffffff",
bg: "#000000",
negativePolarity: true,
minExpected: 100.0,
},
{
name: "black on white (positive polarity)",
fg: "#000000",
bg: "#ffffff",
negativePolarity: false,
minExpected: 100.0,
},
{
name: "low contrast same color",
fg: "#808080",
bg: "#808080",
negativePolarity: false,
minExpected: -40.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeltaPhiStar(tt.fg, tt.bg, tt.negativePolarity)
if result < tt.minExpected {
t.Errorf("DeltaPhiStar(%s, %s, %v) = %f, expected >= %f",
tt.fg, tt.bg, tt.negativePolarity, result, tt.minExpected)
}
})
}
}
func TestDeltaPhiStarContrast(t *testing.T) {
tests := []struct {
name string
fg string
bg string
isLightMode bool
minExpected float64
}{
{
name: "white on black (dark mode)",
fg: "#ffffff",
bg: "#000000",
isLightMode: false,
minExpected: 100.0,
},
{
name: "black on white (light mode)",
fg: "#000000",
bg: "#ffffff",
isLightMode: true,
minExpected: 100.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeltaPhiStarContrast(tt.fg, tt.bg, tt.isLightMode)
if result < tt.minExpected {
t.Errorf("DeltaPhiStarContrast(%s, %s, %v) = %f, expected >= %f",
tt.fg, tt.bg, tt.isLightMode, result, tt.minExpected)
}
})
}
}
func TestEnsureContrastDPS(t *testing.T) {
tests := []struct {
name string
color string
bg string
minLc float64
isLightMode bool
}{
{
name: "already sufficient contrast dark mode",
color: "#ffffff",
bg: "#000000",
minLc: 60.0,
isLightMode: false,
},
{
name: "already sufficient contrast light mode",
color: "#000000",
bg: "#ffffff",
minLc: 60.0,
isLightMode: true,
},
{
name: "needs adjustment dark mode",
color: "#404040",
bg: "#1a1a1a",
minLc: 60.0,
isLightMode: false,
},
{
name: "needs adjustment light mode",
color: "#c0c0c0",
bg: "#f8f8f8",
minLc: 60.0,
isLightMode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EnsureContrastDPS(tt.color, tt.bg, tt.minLc, tt.isLightMode)
actualLc := DeltaPhiStarContrast(result, tt.bg, tt.isLightMode)
if actualLc < tt.minLc {
t.Errorf("EnsureContrastDPS(%s, %s, %f, %t) = %s with Lc %f, expected Lc >= %f",
tt.color, tt.bg, tt.minLc, tt.isLightMode, result, actualLc, tt.minLc)
}
})
}
}
func TestGeneratePaletteWithDPS(t *testing.T) {
tests := []struct {
name string
base string
opts PaletteOptions
}{
{
name: "dark theme with DPS",
base: "#625690",
opts: PaletteOptions{IsLight: false, UseDPS: true},
},
{
name: "light theme with DPS",
base: "#625690",
opts: PaletteOptions{IsLight: true, UseDPS: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GeneratePalette(tt.base, tt.opts)
if len(result) != 16 {
t.Errorf("GeneratePalette returned %d colors, expected 16", len(result))
}
for i, color := range result {
if len(color) != 7 || color[0] != '#' {
t.Errorf("Color at index %d (%s) is not a valid hex color", i, color)
}
}
bgColor := result[0]
for i := 1; i < 8; i++ {
lc := DeltaPhiStarContrast(result[i], bgColor, tt.opts.IsLight)
minLc := 30.0
if lc < minLc && lc > 0 {
t.Errorf("Color %d (%s) has insufficient DPS contrast %f with background %s (expected >= %f)",
i, result[i], lc, bgColor, minLc)
}
}
})
}
}
func TestDeriveContainer(t *testing.T) {
tests := []struct {
name string
primary string
isLight bool
expected string
}{
{
name: "dark mode",
primary: "#ccbdff",
isLight: false,
expected: "#4a3e76",
},
{
name: "light mode",
primary: "#625690",
isLight: true,
expected: "#e7deff",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeriveContainer(tt.primary, tt.isLight)
resultRGB := HexToRGB(result)
expectedRGB := HexToRGB(tt.expected)
rDiff := math.Abs(resultRGB.R - expectedRGB.R)
gDiff := math.Abs(resultRGB.G - expectedRGB.G)
bDiff := math.Abs(resultRGB.B - expectedRGB.B)
tolerance := 0.02
if rDiff > tolerance || gDiff > tolerance || bDiff > tolerance {
t.Errorf("DeriveContainer(%s, %v) = %s, expected %s (RGB diff: R:%.4f G:%.4f B:%.4f)",
tt.primary, tt.isLight, result, tt.expected, rDiff, gDiff, bDiff)
}
})
}
}
func TestContrastAlgorithmComparison(t *testing.T) {
base := "#625690"
optsWCAG := PaletteOptions{IsLight: false, UseDPS: false}
optsDPS := PaletteOptions{IsLight: false, UseDPS: true}
paletteWCAG := GeneratePalette(base, optsWCAG)
paletteDPS := GeneratePalette(base, optsDPS)
if len(paletteWCAG) != 16 || len(paletteDPS) != 16 {
t.Fatal("Both palettes should have 16 colors")
}
if paletteWCAG[0] != paletteDPS[0] {
t.Errorf("Background colors differ: WCAG=%s, DPS=%s", paletteWCAG[0], paletteDPS[0])
}
differentCount := 0
for i := 0; i < 16; i++ {
if paletteWCAG[i] != paletteDPS[i] {
differentCount++
}
}
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
}

View File

@@ -0,0 +1,126 @@
package dank16
import (
"encoding/json"
"fmt"
"strings"
)
func GenerateJSON(colors []string) string {
colorMap := make(map[string]string)
for i, color := range colors {
colorMap[fmt.Sprintf("color%d", i)] = color
}
marshalled, _ := json.Marshal(colorMap)
return string(marshalled)
}
func GenerateKittyTheme(colors []string) string {
kittyColors := []struct {
name string
index int
}{
{"color0", 0},
{"color1", 1},
{"color2", 2},
{"color3", 3},
{"color4", 4},
{"color5", 5},
{"color6", 6},
{"color7", 7},
{"color8", 8},
{"color9", 9},
{"color10", 10},
{"color11", 11},
{"color12", 12},
{"color13", 13},
{"color14", 14},
{"color15", 15},
}
var result strings.Builder
for _, kc := range kittyColors {
fmt.Fprintf(&result, "%s %s\n", kc.name, colors[kc.index])
}
return result.String()
}
func GenerateFootTheme(colors []string) string {
footColors := []struct {
name string
index int
}{
{"regular0", 0},
{"regular1", 1},
{"regular2", 2},
{"regular3", 3},
{"regular4", 4},
{"regular5", 5},
{"regular6", 6},
{"regular7", 7},
{"bright0", 8},
{"bright1", 9},
{"bright2", 10},
{"bright3", 11},
{"bright4", 12},
{"bright5", 13},
{"bright6", 14},
{"bright7", 15},
}
var result strings.Builder
for _, fc := range footColors {
fmt.Fprintf(&result, "%s=%s\n", fc.name, strings.TrimPrefix(colors[fc.index], "#"))
}
return result.String()
}
func GenerateAlacrittyTheme(colors []string) string {
alacrittyColors := []struct {
section string
name string
index int
}{
{"normal", "black", 0},
{"normal", "red", 1},
{"normal", "green", 2},
{"normal", "yellow", 3},
{"normal", "blue", 4},
{"normal", "magenta", 5},
{"normal", "cyan", 6},
{"normal", "white", 7},
{"bright", "black", 8},
{"bright", "red", 9},
{"bright", "green", 10},
{"bright", "yellow", 11},
{"bright", "blue", 12},
{"bright", "magenta", 13},
{"bright", "cyan", 14},
{"bright", "white", 15},
}
var result strings.Builder
currentSection := ""
for _, ac := range alacrittyColors {
if ac.section != currentSection {
if currentSection != "" {
result.WriteString("\n")
}
fmt.Fprintf(&result, "[colors.%s]\n", ac.section)
currentSection = ac.section
}
fmt.Fprintf(&result, "%-7s = '%s'\n", ac.name, colors[ac.index])
}
return result.String()
}
func GenerateGhosttyTheme(colors []string) string {
var result strings.Builder
for i, color := range colors {
fmt.Fprintf(&result, "palette = %d=%s\n", i, color)
}
return result.String()
}

250
internal/dank16/vscode.go Normal file
View File

@@ -0,0 +1,250 @@
package dank16
import (
"encoding/json"
"fmt"
)
type VSCodeTheme struct {
Schema string `json:"$schema"`
Name string `json:"name"`
Type string `json:"type"`
Colors map[string]string `json:"colors"`
TokenColors []VSCodeTokenColor `json:"tokenColors"`
SemanticHighlighting bool `json:"semanticHighlighting"`
SemanticTokenColors map[string]VSCodeTokenSetting `json:"semanticTokenColors"`
}
type VSCodeTokenColor struct {
Scope interface{} `json:"scope"`
Settings VSCodeTokenSetting `json:"settings"`
}
type VSCodeTokenSetting struct {
Foreground string `json:"foreground,omitempty"`
FontStyle string `json:"fontStyle,omitempty"`
}
func updateTokenColor(tc interface{}, scopeToColor map[string]string) {
tcMap, ok := tc.(map[string]interface{})
if !ok {
return
}
scopes, ok := tcMap["scope"].([]interface{})
if !ok {
return
}
settings, ok := tcMap["settings"].(map[string]interface{})
if !ok {
return
}
isYaml := hasScopeContaining(scopes, "yaml")
for _, scope := range scopes {
scopeStr, ok := scope.(string)
if !ok {
continue
}
if scopeStr == "string" && isYaml {
continue
}
if applyColorToScope(settings, scope, scopeToColor) {
break
}
}
}
func applyColorToScope(settings map[string]interface{}, scope interface{}, scopeToColor map[string]string) bool {
scopeStr, ok := scope.(string)
if !ok {
return false
}
newColor, exists := scopeToColor[scopeStr]
if !exists {
return false
}
settings["foreground"] = newColor
return true
}
func hasScopeContaining(scopes []interface{}, substring string) bool {
for _, scope := range scopes {
scopeStr, ok := scope.(string)
if !ok {
continue
}
for i := 0; i <= len(scopeStr)-len(substring); i++ {
if scopeStr[i:i+len(substring)] == substring {
return true
}
}
}
return false
}
func EnrichVSCodeTheme(themeData []byte, colors []string) ([]byte, error) {
var theme map[string]interface{}
if err := json.Unmarshal(themeData, &theme); err != nil {
return nil, err
}
colorsMap, ok := theme["colors"].(map[string]interface{})
if !ok {
colorsMap = make(map[string]interface{})
theme["colors"] = colorsMap
}
bg := colors[0]
isLight := false
if len(bg) == 7 && bg[0] == '#' {
r, g, b := 0, 0, 0
fmt.Sscanf(bg[1:], "%02x%02x%02x", &r, &g, &b)
luminance := (0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)) / 255.0
isLight = luminance > 0.5
}
if isLight {
theme["type"] = "light"
} else {
theme["type"] = "dark"
}
colorsMap["terminal.ansiBlack"] = colors[0]
colorsMap["terminal.ansiRed"] = colors[1]
colorsMap["terminal.ansiGreen"] = colors[2]
colorsMap["terminal.ansiYellow"] = colors[3]
colorsMap["terminal.ansiBlue"] = colors[4]
colorsMap["terminal.ansiMagenta"] = colors[5]
colorsMap["terminal.ansiCyan"] = colors[6]
colorsMap["terminal.ansiWhite"] = colors[7]
colorsMap["terminal.ansiBrightBlack"] = colors[8]
colorsMap["terminal.ansiBrightRed"] = colors[9]
colorsMap["terminal.ansiBrightGreen"] = colors[10]
colorsMap["terminal.ansiBrightYellow"] = colors[11]
colorsMap["terminal.ansiBrightBlue"] = colors[12]
colorsMap["terminal.ansiBrightMagenta"] = colors[13]
colorsMap["terminal.ansiBrightCyan"] = colors[14]
colorsMap["terminal.ansiBrightWhite"] = colors[15]
tokenColors, ok := theme["tokenColors"].([]interface{})
if ok {
scopeToColor := map[string]string{
"comment": colors[8],
"punctuation.definition.comment": colors[8],
"keyword": colors[5],
"storage.type": colors[13],
"storage.modifier": colors[5],
"variable": colors[15],
"variable.parameter": colors[7],
"meta.object-literal.key": colors[4],
"meta.property.object": colors[4],
"variable.other.property": colors[4],
"constant.other.symbol": colors[12],
"constant.numeric": colors[12],
"constant.language": colors[12],
"constant.character": colors[3],
"entity.name.type": colors[12],
"support.type": colors[13],
"entity.name.class": colors[12],
"entity.name.function": colors[2],
"support.function": colors[2],
"support.class": colors[15],
"support.variable": colors[15],
"variable.language": colors[12],
"entity.name.tag.yaml": colors[12],
"string.unquoted.plain.out.yaml": colors[15],
"string.unquoted.yaml": colors[15],
"string": colors[3],
}
for i, tc := range tokenColors {
updateTokenColor(tc, scopeToColor)
tokenColors[i] = tc
}
yamlRules := []VSCodeTokenColor{
{
Scope: "entity.name.tag.yaml",
Settings: VSCodeTokenSetting{Foreground: colors[12]},
},
{
Scope: []string{"string.unquoted.plain.out.yaml", "string.unquoted.yaml"},
Settings: VSCodeTokenSetting{Foreground: colors[15]},
},
}
for _, rule := range yamlRules {
tokenColors = append(tokenColors, rule)
}
theme["tokenColors"] = tokenColors
}
if semanticTokenColors, ok := theme["semanticTokenColors"].(map[string]interface{}); ok {
updates := map[string]string{
"variable": colors[15],
"variable.readonly": colors[12],
"property": colors[4],
"function": colors[2],
"method": colors[2],
"type": colors[12],
"class": colors[12],
"typeParameter": colors[13],
"enumMember": colors[12],
"string": colors[3],
"number": colors[12],
"comment": colors[8],
"keyword": colors[5],
"operator": colors[15],
"parameter": colors[7],
"namespace": colors[15],
}
for key, color := range updates {
if existing, ok := semanticTokenColors[key].(map[string]interface{}); ok {
existing["foreground"] = color
} else {
semanticTokenColors[key] = map[string]interface{}{
"foreground": color,
}
}
}
} else {
semanticTokenColors := make(map[string]interface{})
updates := map[string]string{
"variable": colors[7],
"variable.readonly": colors[12],
"property": colors[4],
"function": colors[2],
"method": colors[2],
"type": colors[12],
"class": colors[12],
"typeParameter": colors[13],
"enumMember": colors[12],
"string": colors[3],
"number": colors[12],
"comment": colors[8],
"keyword": colors[5],
"operator": colors[15],
"parameter": colors[7],
"namespace": colors[15],
}
for key, color := range updates {
semanticTokenColors[key] = map[string]interface{}{
"foreground": color,
}
}
theme["semanticTokenColors"] = semanticTokenColors
}
return json.MarshalIndent(theme, "", " ")
}

51
internal/deps/detector.go Normal file
View File

@@ -0,0 +1,51 @@
package deps
import (
"context"
)
type DependencyStatus int
const (
StatusMissing DependencyStatus = iota
StatusInstalled
StatusNeedsUpdate
StatusNeedsReinstall
)
type PackageVariant int
const (
VariantStable PackageVariant = iota
VariantGit
)
type Dependency struct {
Name string
Status DependencyStatus
Version string
Description string
Required bool
Variant PackageVariant
CanToggle bool
}
type WindowManager int
const (
WindowManagerHyprland WindowManager = iota
WindowManagerNiri
)
type Terminal int
const (
TerminalGhostty Terminal = iota
TerminalKitty
TerminalAlacritty
)
type DependencyDetector interface {
DetectDependencies(ctx context.Context, wm WindowManager) ([]Dependency, error)
DetectDependenciesWithTerminal(ctx context.Context, wm WindowManager, terminal Terminal) ([]Dependency, error)
}

1000
internal/distros/arch.go Normal file

File diff suppressed because it is too large Load Diff

668
internal/distros/base.go Normal file
View File

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

View File

@@ -0,0 +1,220 @@
package distros
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/AvengeMedia/danklinux/internal/deps"
)
func TestBaseDistribution_detectDMS_NotInstalled(t *testing.T) {
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
tempDir := t.TempDir()
os.Setenv("HOME", tempDir)
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status != deps.StatusMissing {
t.Errorf("Expected StatusMissing, got %d", dep.Status)
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
}
func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
if !commandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status == deps.StatusMissing {
t.Error("Expected DMS to be detected as installed")
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
t.Logf("Status: %d, Version: %s", dep.Status, dep.Version)
}
func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
if !commandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
exec.Command("git", "-C", dmsPath, "remote", "add", "origin", "https://github.com/AvengeMedia/DankMaterialShell.git").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
exec.Command("git", "-C", dmsPath, "tag", "v0.0.1").Run()
exec.Command("git", "-C", dmsPath, "checkout", "v0.0.1").Run()
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
t.Logf("Status: %d, Version: %s", dep.Status, dep.Version)
}
func TestBaseDistribution_detectDMS_DirectoryWithoutGit(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status == deps.StatusMissing {
t.Error("Expected DMS to be detected as present")
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
}
func TestBaseDistribution_NewBaseDistribution(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
if base == nil {
t.Fatal("NewBaseDistribution returned nil")
}
if base.logChan == nil {
t.Error("logChan was not set")
}
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func TestBaseDistribution_versionCompare(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
tests := []struct {
v1 string
v2 string
expected int
}{
{"0.1.0", "0.1.0", 0},
{"0.1.0", "0.1.1", -1},
{"0.1.1", "0.1.0", 1},
{"0.2.0", "0.1.9", 1},
{"1.0.0", "0.9.9", 1},
}
for _, tt := range tests {
result := base.versionCompare(tt.v1, tt.v2)
if result != tt.expected {
t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
}
}
}
func TestBaseDistribution_versionCompare_WithPrefix(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
tests := []struct {
v1 string
v2 string
expected int
}{
{"v0.1.0", "v0.1.0", 0},
{"v0.1.0", "v0.1.1", -1},
{"v0.1.1", "v0.1.0", 1},
}
for _, tt := range tests {
result := base.versionCompare(tt.v1, tt.v2)
if result != tt.expected {
t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
}
}
}

533
internal/distros/debian.go Normal file
View File

@@ -0,0 +1,533 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/danklinux/internal/deps"
)
func init() {
Register("debian", "#A80030", FamilyDebian, func(config DistroConfig, logChan chan<- string) Distribution {
return NewDebianDistribution(config, logChan)
})
}
type DebianDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewDebianDistribution(config DistroConfig, logChan chan<- string) *DebianDistribution {
base := NewBaseDistribution(logChan)
return &DebianDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (d *DebianDistribution) GetID() string {
return d.config.ID
}
func (d *DebianDistribution) GetColorHex() string {
return d.config.ColorHex
}
func (d *DebianDistribution) GetFamily() DistroFamily {
return d.config.Family
}
func (d *DebianDistribution) GetPackageManager() PackageManagerType {
return PackageManagerAPT
}
func (d *DebianDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return d.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, d.detectDMS())
dependencies = append(dependencies, d.detectSpecificTerminal(terminal))
dependencies = append(dependencies, d.detectGit())
dependencies = append(dependencies, d.detectWindowManager(wm))
dependencies = append(dependencies, d.detectQuickshell())
dependencies = append(dependencies, d.detectXDGPortal())
dependencies = append(dependencies, d.detectPolkitAgent())
dependencies = append(dependencies, d.detectAccountsService())
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, d.detectXwaylandSatellite())
}
dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDgop())
dependencies = append(dependencies, d.detectHyprpicker())
dependencies = append(dependencies, d.detectClipboardTools()...)
return dependencies, nil
}
func (d *DebianDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (d *DebianDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (d *DebianDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if d.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (d *DebianDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (d *DebianDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
}
func (d *DebianDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "git", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"niri": {Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"ghostty": {Name: "ghostty", Repository: RepoTypeManual, BuildFunc: "installGhostty"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"cliphist": {Name: "cliphist", Repository: RepoTypeManual, BuildFunc: "installCliphist"},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeManual, BuildFunc: "installHyprpicker"},
}
if wm == deps.WindowManagerNiri {
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeManual, BuildFunc: "installXwaylandSatellite"}
}
return packages
}
func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Updating package lists...",
IsComplete: false,
LogOutput: "Updating APT package lists",
}
updateCmd := execSudoCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: "Installing build-essential...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y build-essential",
LogOutput: "Installing build tools",
}
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
cmd := execSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := execSudoCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.12,
Step: "Prerequisites installation complete",
IsComplete: false,
LogOutput: "Prerequisites successfully installed",
}
return nil
}
func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := d.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, manualPkgs := d.categorizePackages(dependencies, wm, reinstallFlags)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := d.installAPTPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install APT packages: %w", err)
}
}
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.80,
Step: "Installing build dependencies...",
IsComplete: false,
LogOutput: "Installing build tools for manual compilation",
}
if err := d.installBuildDependencies(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install build dependencies: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := d.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (d *DebianDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool) ([]string, []string) {
systemPkgs := []string{}
manualPkgs := []string{}
packageMap := d.GetPackageMapping(wm)
for _, dep := range dependencies {
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
d.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, manualPkgs
}
func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
d.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
buildDeps := make(map[string]bool)
for _, pkg := range manualPkgs {
switch pkg {
case "niri":
buildDeps["curl"] = true
buildDeps["libxkbcommon-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["libudev-dev"] = true
buildDeps["libinput-dev"] = true
buildDeps["libdisplay-info-dev"] = true
buildDeps["libpango1.0-dev"] = true
buildDeps["libcairo-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libc6-dev"] = true
buildDeps["clang"] = true
buildDeps["libseat-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["alacritty"] = true
buildDeps["fuzzel"] = true
case "quickshell":
buildDeps["qt6-base-dev"] = true
buildDeps["qt6-base-private-dev"] = true
buildDeps["qt6-declarative-dev"] = true
buildDeps["qt6-declarative-private-dev"] = true
buildDeps["qt6-wayland-dev"] = true
buildDeps["qt6-wayland-private-dev"] = true
buildDeps["qt6-tools-dev"] = true
buildDeps["libqt6svg6-dev"] = true
buildDeps["qt6-shadertools-dev"] = true
buildDeps["spirv-tools"] = true
buildDeps["libcli11-dev"] = true
buildDeps["libjemalloc-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["wayland-protocols"] = true
buildDeps["libdrm-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["libegl-dev"] = true
buildDeps["libgles2-mesa-dev"] = true
buildDeps["libgl1-mesa-dev"] = true
buildDeps["libxcb1-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libpam0g-dev"] = true
case "ghostty":
buildDeps["curl"] = true
case "matugen":
buildDeps["curl"] = true
}
}
for _, pkg := range manualPkgs {
switch pkg {
case "niri", "matugen":
if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "cliphist", "dgop":
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
}
}
}
if len(buildDeps) == 0 {
return nil
}
depList := make([]string, 0, len(buildDeps))
for dep := range buildDeps {
depList = append(depList, dep)
}
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if d.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := execSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := d.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
if !d.commandExists("cargo") {
d.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if d.commandExists("go") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.87,
Step: "Installing Go...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := execSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}
func (d *DebianDistribution) installGhosttyDebian(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
d.log("Installing Ghostty using Debian installer script...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Running Ghostty Debian installer...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | sudo bash",
LogOutput: "Installing Ghostty using pre-built Debian package",
}
installCmd := execSudoCommand(ctx, sudoPassword,
"/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)\"")
if err := d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.9); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
d.log("Ghostty installed successfully using Debian installer")
return nil
}
func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
d.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
switch pkg {
case "ghostty":
if err := d.installGhosttyDebian(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
default:
if err := d.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

View File

@@ -0,0 +1,19 @@
package distros
import (
"github.com/AvengeMedia/danklinux/internal/deps"
)
// NewDependencyDetector creates a DependencyDetector for the specified distribution
func NewDependencyDetector(distribution string, logChan chan<- string) (deps.DependencyDetector, error) {
distro, err := NewDistribution(distribution, logChan)
if err != nil {
return nil, err
}
return distro, nil
}
// NewPackageInstaller creates a Distribution for package installation
func NewPackageInstaller(distribution string, logChan chan<- string) (Distribution, error) {
return NewDistribution(distribution, logChan)
}

550
internal/distros/fedora.go Normal file
View File

@@ -0,0 +1,550 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/danklinux/internal/deps"
)
func init() {
Register("fedora", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("nobara", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
}
type FedoraDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
base := NewBaseDistribution(logChan)
return &FedoraDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (f *FedoraDistribution) GetID() string {
return f.config.ID
}
func (f *FedoraDistribution) GetColorHex() string {
return f.config.ColorHex
}
func (f *FedoraDistribution) GetFamily() DistroFamily {
return f.config.Family
}
func (f *FedoraDistribution) GetPackageManager() PackageManagerType {
return PackageManagerDNF
}
func (f *FedoraDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return f.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, f.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, f.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, f.detectGit())
dependencies = append(dependencies, f.detectWindowManager(wm))
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectXDGPortal())
dependencies = append(dependencies, f.detectPolkitAgent())
dependencies = append(dependencies, f.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, f.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectHyprpicker())
dependencies = append(dependencies, f.detectClipboardTools()...)
return dependencies, nil
}
func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (f *FedoraDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
return err == nil
}
func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
// Standard DNF packages
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": f.getHyprpickerMapping(variants["hyprland"]),
// COPR packages
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"cliphist": {Name: "cliphist", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = f.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = f.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeCOPR, RepoURL: "yalter/niri"}
}
return packages
}
func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms-git"}
}
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms"}
}
func (f *FedoraDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprland-git", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
func (f *FedoraDistribution) getHyprpickerMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprpicker-git", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
return PackageMapping{Name: "hyprpicker", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri-git"}
}
return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri"}
}
func (f *FedoraDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if f.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (f *FedoraDistribution) getPrerequisites() []string {
return []string{
"dnf-plugins-core",
"make",
"unzip",
"libwayland-server",
}
}
func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := f.getPrerequisites()
var missingPkgs []string
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
f.log("go not found in PATH, will install golang-bin")
missingPkgs = append(missingPkgs, "golang-bin")
} else {
f.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
f.log("All prerequisites already installed")
return nil
}
f.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo dnf install -y %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...)
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to install prerequisites", err)
f.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w", err)
}
f.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := f.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
dnfPkgs, coprPkgs, manualPkgs := f.categorizePackages(dependencies, wm, reinstallFlags)
// Phase 2: Enable COPR repositories
if len(coprPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling COPR repositories...",
IsComplete: false,
LogOutput: "Setting up COPR repositories for additional packages",
}
if err := f.enableCOPRRepos(ctx, coprPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable COPR repositories: %w", err)
}
}
// Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(dnfPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(dnfPkgs, ", ")),
}
if err := f.installDNFPackages(ctx, dnfPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install DNF packages: %w", err)
}
}
// Phase 4: COPR Packages
coprPkgNames := f.extractPackageNames(coprPkgs)
if len(coprPkgNames) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, // Reusing AUR phase for COPR
Progress: 0.65,
Step: fmt.Sprintf("Installing %d COPR packages...", len(coprPkgNames)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing COPR packages: %s", strings.Join(coprPkgNames, ", ")),
}
if err := f.installCOPRPackages(ctx, coprPkgNames, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install COPR packages: %w", err)
}
}
// Phase 5: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := f.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 6: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (f *FedoraDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool) ([]string, []PackageMapping, []string) {
dnfPkgs := []string{}
coprPkgs := []PackageMapping{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := f.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
// Skip installed packages unless marked for reinstall
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
f.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
dnfPkgs = append(dnfPkgs, pkgInfo.Name)
case RepoTypeCOPR:
coprPkgs = append(coprPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return dnfPkgs, coprPkgs, manualPkgs
}
func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
for _, pkg := range coprPkgs {
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
f.log(fmt.Sprintf("Enabling COPR repository: %s", pkg.RepoURL))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.20,
Step: fmt.Sprintf("Enabling COPR repo %s...", pkg.RepoURL),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
}
cmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError(fmt.Sprintf("failed to enable COPR repo %s", pkg.RepoURL), err)
f.log(fmt.Sprintf("COPR enable command output: %s", string(output)))
return fmt.Errorf("failed to enable COPR repo %s: %w", pkg.RepoURL, err)
}
f.log(fmt.Sprintf("COPR repo %s enabled successfully: %s", pkg.RepoURL, string(output)))
enabledRepos[pkg.RepoURL] = true
// Special handling for niri COPR repo - set priority=1
if pkg.RepoURL == "yalter/niri-git" {
f.log("Setting priority=1 for niri-git COPR repo...")
repoFile := "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.22,
Step: "Setting niri COPR repo priority...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
}
priorityCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s' 2>&1", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil {
f.logError("failed to set niri COPR repo priority", err)
f.log(fmt.Sprintf("Priority command output: %s", string(priorityOutput)))
return fmt.Errorf("failed to set niri COPR repo priority: %w", err)
}
f.log(fmt.Sprintf("niri COPR repo priority set successfully: %s", string(priorityOutput)))
}
}
}
return nil
}
func (f *FedoraDistribution) installDNFPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
f.log(fmt.Sprintf("Installing DNF packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
f.log(fmt.Sprintf("Installing COPR packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
for _, pkg := range packages {
if pkg == "niri" || pkg == "niri-git" {
args = append(args, "--setopt=install_weak_deps=False")
break
}
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing COPR packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
}

734
internal/distros/gentoo.go Normal file
View File

@@ -0,0 +1,734 @@
package distros
import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"github.com/AvengeMedia/danklinux/internal/deps"
)
var GentooGlobalUseFlags = []string{
"X",
"dbus",
"udev",
"alsa",
"policykit",
"jpeg",
"png",
"webp",
"gif",
"tiff",
"svg",
"brotli",
"gdbm",
"accessibility",
"gtk",
"qt6",
"egl",
"gbm",
}
func init() {
Register("gentoo", "#54487A", FamilyGentoo, func(config DistroConfig, logChan chan<- string) Distribution {
return NewGentooDistribution(config, logChan)
})
}
type GentooDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDistribution {
base := NewBaseDistribution(logChan)
return &GentooDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH
switch arch {
case "amd64":
return "~amd64"
case "arm64":
return "~arm64"
default:
return "~amd64"
}
}
func (g *GentooDistribution) GetID() string {
return g.config.ID
}
func (g *GentooDistribution) GetColorHex() string {
return g.config.ColorHex
}
func (g *GentooDistribution) GetFamily() DistroFamily {
return g.config.Family
}
func (g *GentooDistribution) GetPackageManager() PackageManagerType {
return PackageManagerPortage
}
func (g *GentooDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return g.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, g.detectDMS())
dependencies = append(dependencies, g.detectSpecificTerminal(terminal))
dependencies = append(dependencies, g.detectGit())
dependencies = append(dependencies, g.detectWindowManager(wm))
dependencies = append(dependencies, g.detectQuickshell())
dependencies = append(dependencies, g.detectXDGPortal())
dependencies = append(dependencies, g.detectPolkitAgent())
dependencies = append(dependencies, g.detectAccountsService())
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, g.detectHyprlandTools()...)
}
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectHyprpicker())
dependencies = append(dependencies, g.detectClipboardTools()...)
return dependencies, nil
}
func (g *GentooDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("sys-apps/xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (g *GentooDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("mate-extra/mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("gui-apps/xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (g *GentooDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("sys-apps/accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (g *GentooDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("qlist", "-I", pkg)
err := cmd.Run()
return err == nil
}
func (g *GentooDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return g.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
archKeyword := g.getArchKeyword()
packages := map[string]PackageMapping{
"git": {Name: "dev-vcs/git", Repository: RepoTypeSystem},
"kitty": {Name: "x11-terms/kitty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
"alacritty": {Name: "x11-terms/alacritty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
"wl-clipboard": {Name: "gui-apps/wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "sys-apps/xdg-desktop-portal-gtk", Repository: RepoTypeSystem, UseFlags: "wayland X"},
"mate-polkit": {Name: "mate-extra/mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "sys-apps/accountsservice", Repository: RepoTypeSystem},
"hyprpicker": g.getHyprpickerMapping(variants["hyprland"]),
"qtbase": {Name: "dev-qt/qtbase", Repository: RepoTypeSystem, UseFlags: "wayland opengl vulkan widgets"},
"qtdeclarative": {Name: "dev-qt/qtdeclarative", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"},
"qtwayland": {Name: "dev-qt/qtwayland", Repository: RepoTypeSystem},
"mesa": {Name: "media-libs/mesa", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"},
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"cliphist": {Name: "app-misc/cliphist", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = g.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "gui-apps/grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "gui-apps/slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = g.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "app-misc/jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
}
return packages
}
func (g *GentooDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
}
func (g *GentooDistribution) getDmsMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"}
}
func (g *GentooDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
archKeyword := g.getArchKeyword()
if variant == deps.VariantGit {
return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeGURU, UseFlags: "X", AcceptKeywords: archKeyword}
}
return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeSystem, UseFlags: "X", AcceptKeywords: archKeyword}
}
func (g *GentooDistribution) getHyprpickerMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-apps/hyprpicker", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string {
return []string{
"app-eselect/eselect-repository",
"dev-vcs/git",
"dev-build/make",
"app-arch/unzip",
"dev-util/pkgconf",
"dev-qt/qtdeclarative",
}
}
func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword string) error {
useFlags := strings.Join(GentooGlobalUseFlags, " ")
checkCmd := exec.CommandContext(ctx, "grep", "-q", "^USE=", "/etc/portage/make.conf")
hasUse := checkCmd.Run() == nil
var cmd *exec.Cmd
if hasUse {
cmd = execSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf; exit_code=$?; exit $exit_code", useFlags))
} else {
cmd = execSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"; exit_code=$?; exit $exit_code", useFlags))
}
output, err := cmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("Failed to set global USE flags: %s", string(output)))
return err
}
g.log(fmt.Sprintf("Set global USE flags: %s", useFlags))
return nil
}
func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := g.getPrerequisites()
var missingPkgs []string
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Setting global USE flags...",
IsComplete: false,
LogOutput: "Configuring global USE flags in /etc/portage/make.conf",
}
if err := g.setGlobalUseFlags(ctx, sudoPassword); err != nil {
g.logError("failed to set global USE flags", err)
return fmt.Errorf("failed to set global USE flags: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "qlist", "-I", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
g.log("go not found in PATH, will install dev-lang/go")
missingPkgs = append(missingPkgs, "dev-lang/go")
} else {
g.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
g.log("All prerequisites already installed")
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.07,
Step: "Syncing Portage tree...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo emerge --sync",
LogOutput: "Syncing Portage tree with emerge --sync",
}
syncCmd := execSudoCommand(ctx, sudoPassword,
"emerge --sync --quiet; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput()
if syncErr != nil {
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
return fmt.Errorf("failed to sync Portage tree: %w\nOutput: %s", syncErr, string(syncOutput))
}
g.log("Portage tree synced successfully")
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := execSudoCommand(ctx, sudoPassword, fmt.Sprintf("%s; exit_code=$?; exit $exit_code", strings.Join(args, " ")))
output, err := cmd.CombinedOutput()
if err != nil {
g.logError("failed to install prerequisites", err)
g.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w\nOutput: %s", err, string(output))
}
g.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (g *GentooDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := g.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, guruPkgs, manualPkgs := g.categorizePackages(dependencies, wm, reinstallFlags)
g.log(fmt.Sprintf("CATEGORIZED PACKAGES: system=%d, guru=%d, manual=%d", len(systemPkgs), len(guruPkgs), len(manualPkgs)))
if len(systemPkgs) > 0 {
systemPkgNames := g.extractPackageNames(systemPkgs)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgNames, ", ")),
}
if err := g.installPortagePackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Portage packages: %w", err)
}
}
if len(guruPkgs) > 0 {
g.log(fmt.Sprintf("FOUND %d GURU PACKAGES - WILL SYNC GURU REPO", len(guruPkgs)))
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.60,
Step: "Syncing GURU repository...",
IsComplete: false,
LogOutput: "Syncing GURU repository to fetch latest ebuilds",
}
g.log("ABOUT TO CALL syncGURURepo")
if err := g.syncGURURepo(ctx, sudoPassword, progressChan); err != nil {
g.log(fmt.Sprintf("syncGURURepo RETURNED ERROR: %v", err))
return fmt.Errorf("failed to sync GURU repository: %w", err)
}
g.log("syncGURURepo COMPLETED SUCCESSFULLY")
guruPkgNames := g.extractPackageNames(guruPkgs)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d GURU packages...", len(guruPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing GURU packages: %s", strings.Join(guruPkgNames, ", ")),
}
if err := g.installGURUPackages(ctx, guruPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install GURU packages: %w", err)
}
}
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := g.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (g *GentooDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool) ([]PackageMapping, []PackageMapping, []string) {
systemPkgs := []PackageMapping{}
guruPkgs := []PackageMapping{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := g.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
g.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo)
case RepoTypeGURU:
guruPkgs = append(guruPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, guruPkgs, manualPkgs
}
func (g *GentooDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (g *GentooDistribution) installPortagePackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
packageNames := g.extractPackageNames(packages)
g.log(fmt.Sprintf("Installing Portage packages: %s", strings.Join(packageNames, ", ")))
for _, pkg := range packages {
if pkg.AcceptKeywords != "" {
if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil {
return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err)
}
}
if pkg.UseFlags != "" {
if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil {
return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err)
}
}
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, fmt.Sprintf("%s || exit $?", strings.Join(args, " ")))
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
}
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
packageUseDir := "/etc/portage/package.use"
mkdirCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", packageUseDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
return fmt.Errorf("failed to create package.use directory: %w", err)
}
useFlagLine := fmt.Sprintf("%s %s", packageName, useFlags)
checkExistingCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, packageUseDir))
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
return fmt.Errorf("failed to remove old USE flags: %w", err)
}
}
appendCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
output, err := appendCmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("append output: %s", string(output)))
return fmt.Errorf("failed to write USE flags to package.use: %w", err)
}
g.log(fmt.Sprintf("Set USE flags for %s: %s", packageName, useFlags))
return nil
}
func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
Step: "Enabling GURU repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo eselect repository enable guru",
LogOutput: "Enabling GURU repository with eselect",
}
// Enable GURU repository
enableCmd := execSudoCommand(ctx, sudoPassword,
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
output, err := enableCmd.CombinedOutput()
g.log(fmt.Sprintf("eselect repository enable guru output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
LogOutput: "GURU repository enabled",
}
if err != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
LogOutput: fmt.Sprintf("ERROR enabling GURU: %v", err),
Error: err,
}
return fmt.Errorf("failed to enable GURU repository: %w\nOutput: %s", err, string(output))
}
// Sync GURU repository
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
Step: "Syncing GURU repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo emaint sync --repo guru",
LogOutput: "Syncing GURU repository",
}
syncCmd := execSudoCommand(ctx, sudoPassword,
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput()
g.log(fmt.Sprintf("emaint sync --repo guru output:\n%s", string(syncOutput)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
LogOutput: "GURU repository synced",
}
if syncErr != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
LogOutput: fmt.Sprintf("ERROR syncing GURU: %v", syncErr),
Error: syncErr,
}
return fmt.Errorf("failed to sync GURU repository: %w\nOutput: %s", syncErr, string(syncOutput))
}
return nil
}
func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packageName, keywords, sudoPassword string) error {
checkCmd := exec.CommandContext(ctx, "portageq", "match", "/", packageName)
if output, err := checkCmd.CombinedOutput(); err == nil && len(output) > 0 {
g.log(fmt.Sprintf("Package %s is already available (may already be unmasked)", packageName))
return nil
}
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
mkdirCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
return fmt.Errorf("failed to create package.accept_keywords directory: %w", err)
}
keywordLine := fmt.Sprintf("%s %s", packageName, keywords)
checkExistingCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, acceptKeywordsDir))
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
return fmt.Errorf("failed to remove old accept keywords: %w", err)
}
}
appendCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
output, err := appendCmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("append output: %s", string(output)))
return fmt.Errorf("failed to write accept keywords: %w", err)
}
g.log(fmt.Sprintf("Set accept keywords for %s: %s", packageName, keywords))
return nil
}
func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
packageNames := g.extractPackageNames(packages)
g.log(fmt.Sprintf("Installing GURU packages: %s", strings.Join(packageNames, ", ")))
for _, pkg := range packages {
if pkg.AcceptKeywords != "" {
if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil {
return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err)
}
}
if pkg.UseFlags != "" {
if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil {
return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err)
}
}
}
guruPackages := make([]string, len(packageNames))
for i, pkg := range packageNames {
guruPackages[i] = pkg + "::guru"
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing GURU packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, fmt.Sprintf("%s || exit $?", strings.Join(args, " ")))
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
}

View File

@@ -0,0 +1,156 @@
package distros
import (
"context"
"github.com/AvengeMedia/danklinux/internal/deps"
)
// DistroFamily represents a family of related distributions
type DistroFamily string
const (
FamilyArch DistroFamily = "arch"
FamilyFedora DistroFamily = "fedora"
FamilySUSE DistroFamily = "suse"
FamilyUbuntu DistroFamily = "ubuntu"
FamilyDebian DistroFamily = "debian"
FamilyNix DistroFamily = "nix"
FamilyGentoo DistroFamily = "gentoo"
)
// PackageManagerType defines the package manager a distro uses
type PackageManagerType string
const (
PackageManagerPacman PackageManagerType = "pacman"
PackageManagerDNF PackageManagerType = "dnf"
PackageManagerAPT PackageManagerType = "apt"
PackageManagerZypper PackageManagerType = "zypper"
PackageManagerNix PackageManagerType = "nix"
PackageManagerPortage PackageManagerType = "portage"
)
// RepositoryType defines the type of repository for a package
type RepositoryType string
const (
RepoTypeSystem RepositoryType = "system" // Standard system repo (pacman, dnf, apt)
RepoTypeAUR RepositoryType = "aur" // Arch User Repository
RepoTypeCOPR RepositoryType = "copr" // Fedora COPR
RepoTypePPA RepositoryType = "ppa" // Ubuntu PPA
RepoTypeFlake RepositoryType = "flake" // Nix flake
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
RepoTypeManual RepositoryType = "manual" // Manual build from source
)
// InstallPhase represents the current phase of installation
type InstallPhase int
const (
PhasePrerequisites InstallPhase = iota
PhaseAURHelper
PhaseSystemPackages
PhaseAURPackages
PhaseCursorTheme
PhaseConfiguration
PhaseComplete
)
// InstallProgressMsg represents progress during package installation
type InstallProgressMsg struct {
Phase InstallPhase
Progress float64
Step string
IsComplete bool
NeedsSudo bool
CommandInfo string
LogOutput string
Error error
}
// PackageMapping defines how to install a package on a specific distro
type PackageMapping struct {
Name string // Package name to install
Repository RepositoryType // Repository type
RepoURL string // Repository URL if needed (e.g., COPR repo, PPA)
BuildFunc string // Name of manual build function if RepoTypeManual
UseFlags string // USE flags for Gentoo packages
AcceptKeywords string // Accept keywords for Gentoo packages (e.g., "~amd64")
}
// Distribution defines a Linux distribution with all its specific configurations
type Distribution interface {
// Metadata
GetID() string
GetColorHex() string
GetFamily() DistroFamily
GetPackageManager() PackageManagerType
// Dependency Detection
DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error)
DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error)
// Package Installation
InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, progressChan chan<- InstallProgressMsg) error
// Package Mapping
GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping
// Prerequisites
InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error
}
// DistroConfig holds configuration for a distribution
type DistroConfig struct {
ID string
ColorHex string
Family DistroFamily
Constructor func(config DistroConfig, logChan chan<- string) Distribution
}
// Registry holds all supported distributions
var Registry = make(map[string]DistroConfig)
// Register adds a distribution to the registry
func Register(id, colorHex string, family DistroFamily, constructor func(config DistroConfig, logChan chan<- string) Distribution) {
Registry[id] = DistroConfig{
ID: id,
ColorHex: colorHex,
Family: family,
Constructor: constructor,
}
}
// GetSupportedDistros returns a list of all supported distribution IDs
func GetSupportedDistros() []string {
ids := make([]string, 0, len(Registry))
for id := range Registry {
ids = append(ids, id)
}
return ids
}
// IsDistroSupported checks if a distribution ID is supported
func IsDistroSupported(id string) bool {
_, exists := Registry[id]
return exists
}
// NewDistribution creates a distribution instance by ID
func NewDistribution(id string, logChan chan<- string) (Distribution, error) {
config, exists := Registry[id]
if !exists {
return nil, &UnsupportedDistributionError{ID: id}
}
return config.Constructor(config, logChan), nil
}
// UnsupportedDistributionError is returned when a distribution is not supported
type UnsupportedDistributionError struct {
ID string
}
func (e *UnsupportedDistributionError) Error() string {
return "unsupported distribution: " + e.ID
}

View File

@@ -0,0 +1,802 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ManualPackageInstaller provides methods for installing packages from source
type ManualPackageInstaller struct {
*BaseDistribution
}
// parseLatestTagFromGitOutput parses git ls-remote output and returns the latest tag
func (m *ManualPackageInstaller) parseLatestTagFromGitOutput(output string) string {
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "refs/tags/") && !strings.Contains(line, "^{}") {
parts := strings.Split(line, "refs/tags/")
if len(parts) > 1 {
latestTag := strings.TrimSpace(parts[1])
return latestTag
}
}
}
return ""
}
// getLatestQuickshellTag fetches the latest tag from the quickshell repository
func (m *ManualPackageInstaller) getLatestQuickshellTag(ctx context.Context) string {
tagCmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", "--sort=-v:refname",
"https://github.com/quickshell-mirror/quickshell.git")
tagOutput, err := tagCmd.Output()
if err != nil {
m.log(fmt.Sprintf("Warning: failed to fetch quickshell tags: %v", err))
return ""
}
return m.parseLatestTagFromGitOutput(string(tagOutput))
}
// InstallManualPackages handles packages that need manual building
func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
m.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
switch pkg {
case "dms (DankMaterialShell)", "dms":
if err := m.installDankMaterialShell(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install DankMaterialShell: %w", err)
}
case "dgop":
if err := m.installDgop(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install dgop: %w", err)
}
case "grimblast":
if err := m.installGrimblast(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install grimblast: %w", err)
}
case "niri":
if err := m.installNiri(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install niri: %w", err)
}
case "quickshell":
if err := m.installQuickshell(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
case "hyprland":
if err := m.installHyprland(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install hyprland: %w", err)
}
case "hyprpicker":
if err := m.installHyprpicker(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install hyprpicker: %w", err)
}
case "ghostty":
if err := m.installGhostty(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
case "matugen":
if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install matugen: %w", err)
}
case "cliphist":
if err := m.installCliphist(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install cliphist: %w", err)
}
case "xwayland-satellite":
if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
}
default:
m.log(fmt.Sprintf("Warning: No manual build method for %s", pkg))
}
}
return nil
}
func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing dgop from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "dgop-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning dgop repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/dgop.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/AvengeMedia/dgop.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
m.logError("failed to clone dgop repository", err)
return fmt.Errorf("failed to clone dgop repository: %w", err)
}
buildCmd := exec.CommandContext(ctx, "make")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.7, "Building dgop..."); err != nil {
return fmt.Errorf("failed to build dgop: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing dgop...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := execSudoCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err)
return fmt.Errorf("failed to install dgop: %w", err)
}
m.log("dgop installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installGrimblast(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing grimblast script for Hyprland...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Downloading grimblast script...",
IsComplete: false,
CommandInfo: "curl grimblast script",
}
grimblastURL := "https://raw.githubusercontent.com/hyprwm/contrib/refs/heads/main/grimblast/grimblast"
tmpPath := filepath.Join(os.TempDir(), "grimblast")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", "-o", tmpPath, grimblastURL)
if err := downloadCmd.Run(); err != nil {
m.logError("failed to download grimblast", err)
return fmt.Errorf("failed to download grimblast: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.5,
Step: "Making grimblast executable...",
IsComplete: false,
CommandInfo: "chmod +x grimblast",
}
chmodCmd := exec.CommandContext(ctx, "chmod", "+x", tmpPath)
if err := chmodCmd.Run(); err != nil {
m.logError("failed to make grimblast executable", err)
return fmt.Errorf("failed to make grimblast executable: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing grimblast to /usr/local/bin...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp grimblast /usr/local/bin/",
}
installCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/grimblast", tmpPath))
if err := installCmd.Run(); err != nil {
m.logError("failed to install grimblast", err)
return fmt.Errorf("failed to install grimblast: %w", err)
}
os.Remove(tmpPath)
m.log("grimblast installed successfully to /usr/local/bin")
return nil
}
func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing niri from source...")
homeDir, _ := os.UserHomeDir()
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build")
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp")
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() {
os.RemoveAll(buildDir)
os.RemoveAll(tmpDir)
}()
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Cloning niri repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/YaLTeR/niri.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/YaLTeR/niri.git", buildDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone niri: %w", err)
}
checkoutCmd := exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "v25.08")
if err := checkoutCmd.Run(); err != nil {
m.log(fmt.Sprintf("Warning: failed to checkout v25.08, using main: %v", err))
}
if !m.commandExists("cargo-deb") {
cargoDebInstallCmd := exec.CommandContext(ctx, "cargo", "install", "cargo-deb")
cargoDebInstallCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir)
if err := m.runWithProgressStep(cargoDebInstallCmd, progressChan, PhaseSystemPackages, 0.3, 0.35, "Installing cargo-deb..."); err != nil {
return fmt.Errorf("failed to install cargo-deb: %w", err)
}
}
buildDebCmd := exec.CommandContext(ctx, "cargo", "deb")
buildDebCmd.Dir = buildDir
buildDebCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir)
if err := m.runWithProgressStep(buildDebCmd, progressChan, PhaseSystemPackages, 0.35, 0.95, "Building niri deb package..."); err != nil {
return fmt.Errorf("failed to build niri deb: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.95,
Step: "Installing niri deb package...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "dpkg -i niri.deb",
}
installDebCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
output, err := installDebCmd.CombinedOutput()
if err != nil {
m.log(fmt.Sprintf("dpkg install failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to install niri deb package: %w\nOutput:\n%s", err, string(output))
}
m.log(fmt.Sprintf("dpkg install successful. Output:\n%s", string(output)))
m.log("niri installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing quickshell from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning quickshell repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git",
}
var cloneCmd *exec.Cmd
if forceQuickshellGit {
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
// Get latest tag from repository
latestTag := m.getLatestQuickshellTag(ctx)
if latestTag != "" {
m.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag))
cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
m.log("Warning: failed to fetch latest tag, using default branch")
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
}
}
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone quickshell: %w", err)
}
buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.3,
Step: "Configuring quickshell build...",
IsComplete: false,
CommandInfo: "cmake -B build -S . -G Ninja",
}
configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build",
"-DCMAKE_BUILD_TYPE=RelWithDebInfo",
"-DCRASH_REPORTER=off",
"-DCMAKE_CXX_STANDARD=20")
configureCmd.Dir = tmpDir
configureCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
output, err := configureCmd.CombinedOutput()
if err != nil {
m.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output))
}
m.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building quickshell (this may take a while)...",
IsComplete: false,
CommandInfo: "cmake --build build",
}
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil {
return fmt.Errorf("failed to build quickshell: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cmake --install build",
}
installCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && cmake --install build", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
m.log("quickshell installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing Hyprland from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "hyprland-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning Hyprland repository...",
IsComplete: false,
CommandInfo: "git clone --recursive https://github.com/hyprwm/Hyprland.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "--recursive", "https://github.com/hyprwm/Hyprland.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone Hyprland: %w", err)
}
checkoutCmd := exec.CommandContext(ctx, "git", "-C", tmpDir, "checkout", "v0.50.1")
if err := checkoutCmd.Run(); err != nil {
m.log(fmt.Sprintf("Warning: failed to checkout v0.50.1, using main: %v", err))
}
buildCmd := exec.CommandContext(ctx, "make", "all")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.2, 0.8, "Building Hyprland..."); err != nil {
return fmt.Errorf("failed to build Hyprland: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing Hyprland...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && make install", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Hyprland: %w", err)
}
m.log("Hyprland installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installHyprpicker(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing hyprpicker from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "hyprpicker-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Cloning hyprpicker repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/hyprwm/hyprpicker.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprpicker.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone hyprpicker: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building hyprpicker...",
IsComplete: false,
CommandInfo: "make all",
}
buildCmd := exec.CommandContext(ctx, "make", "all")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("failed to build hyprpicker: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing hyprpicker...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && make install", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install hyprpicker: %w", err)
}
m.log("hyprpicker installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing Ghostty from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "ghostty-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning Ghostty repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/ghostty-org/ghostty.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/ghostty-org/ghostty.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone Ghostty: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Building Ghostty (this may take a while)...",
IsComplete: false,
CommandInfo: "zig build -Doptimize=ReleaseFast",
}
buildCmd := exec.CommandContext(ctx, "zig", "build", "-Doptimize=ReleaseFast")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("failed to build Ghostty: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing Ghostty...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
}
installCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
m.log("Ghostty installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing matugen from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing matugen via cargo...",
IsComplete: false,
CommandInfo: "cargo install matugen",
}
installCmd := exec.CommandContext(ctx, "cargo", "install", "matugen")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building matugen..."); err != nil {
return fmt.Errorf("failed to install matugen: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, ".cargo", "bin", "matugen")
targetPath := "/usr/local/bin/matugen"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing matugen binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make matugen executable: %w", err)
}
m.log("matugen installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing DankMaterialShell (DMS)...")
// Always install/update the DMS binary
if err := m.installDMSBinary(ctx, sudoPassword, progressChan); err != nil {
m.logError("Failed to install DMS binary", err)
}
// Handle DMS config - clone if missing, pull if exists
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
// Config doesn't exist, clone it
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.90,
Step: "Cloning DankMaterialShell config...",
IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/DankMaterialShell.git ~/.config/quickshell/dms",
}
configDir := filepath.Dir(dmsPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create quickshell config directory: %w", err)
}
cloneCmd := exec.CommandContext(ctx, "git", "clone",
"https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone DankMaterialShell: %w", err)
}
if !forceDMSGit {
fetchCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "fetch", "--tags")
if err := fetchCmd.Run(); err == nil {
tagCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "describe", "--tags", "--abbrev=0", "origin/master")
if tagOutput, err := tagCmd.Output(); err == nil {
latestTag := strings.TrimSpace(string(tagOutput))
checkoutCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "checkout", latestTag)
if err := checkoutCmd.Run(); err == nil {
m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag))
}
}
}
}
m.log("DankMaterialShell config cloned successfully")
} else {
// Config exists, update it
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.90,
Step: "Updating DankMaterialShell config...",
IsComplete: false,
CommandInfo: "git pull in ~/.config/quickshell/dms",
}
pullCmd := exec.CommandContext(ctx, "git", "pull")
pullCmd.Dir = dmsPath
if err := pullCmd.Run(); err != nil {
m.logError("Failed to update DankMaterialShell config", err)
} else {
m.log("DankMaterialShell config updated successfully")
}
}
return nil
}
func (m *ManualPackageInstaller) installCliphist(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing cliphist from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing cliphist via go install...",
IsComplete: false,
CommandInfo: "go install go.senan.xyz/cliphist@latest",
}
installCmd := exec.CommandContext(ctx, "go", "install", "go.senan.xyz/cliphist@latest")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building cliphist..."); err != nil {
return fmt.Errorf("failed to install cliphist: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, "go", "bin", "cliphist")
targetPath := "/usr/local/bin/cliphist"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing cliphist binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy cliphist to /usr/local/bin: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make cliphist executable: %w", err)
}
m.log("cliphist installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing xwayland-satellite from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing xwayland-satellite via cargo...",
IsComplete: false,
CommandInfo: "cargo install --git https://github.com/Supreeeme/xwayland-satellite --tag v0.7",
}
installCmd := exec.CommandContext(ctx, "cargo", "install", "--git", "https://github.com/Supreeeme/xwayland-satellite", "--tag", "v0.7")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building xwayland-satellite..."); err != nil {
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, ".cargo", "bin", "xwayland-satellite")
targetPath := "/usr/local/bin/xwayland-satellite"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing xwayland-satellite binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
}
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
}
m.log("xwayland-satellite installed successfully from source")
return nil
}

View File

@@ -0,0 +1,122 @@
package distros
import (
"testing"
)
func TestManualPackageInstaller_parseLatestTagFromGitOutput(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "normal tag output",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0
703a3789083d2f990c4e99cd25c97c2a4cccbd81 refs/tags/v0.1.0`,
expected: "v0.2.1",
},
{
name: "annotated tags with ^{}",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.2.1^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.2.1",
},
{
name: "mixed tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0
b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.3.0^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0
c1c150fab00a93ea983aaca5df55304bc837f51d refs/tags/beta-1`,
expected: "v0.3.0",
},
{
name: "empty output",
input: "",
expected: "",
},
{
name: "no tags",
input: "some other output\nwithout tags",
expected: "",
},
{
name: "only annotated tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0^{}`,
expected: "",
},
{
name: "single tag",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.0.0`,
expected: "v1.0.0",
},
{
name: "tag with extra whitespace",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.2.1",
},
{
name: "beta and rc tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0-beta.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.3.0-beta.1",
},
{
name: "tags without v prefix",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/0.2.0`,
expected: "0.2.1",
},
{
name: "multiple lines with spaces",
input: `
a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.2.3
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v1.2.2
`,
expected: "v1.2.3",
},
{
name: "tag at end of line",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1`,
expected: "v0.2.1",
},
}
logChan := make(chan string, 100)
defer close(logChan)
base := NewBaseDistribution(logChan)
installer := &ManualPackageInstaller{BaseDistribution: base}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := installer.parseLatestTagFromGitOutput(tt.input)
if result != tt.expected {
t.Errorf("parseLatestTagFromGitOutput() = %q, expected %q", result, tt.expected)
}
})
}
}
func TestManualPackageInstaller_parseLatestTagFromGitOutput_EmptyInstaller(t *testing.T) {
// Test that parsing works even with a minimal installer setup
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
installer := &ManualPackageInstaller{BaseDistribution: base}
input := `abc123 refs/tags/v1.0.0
def456 refs/tags/v0.9.0`
result := installer.parseLatestTagFromGitOutput(input)
if result != "v1.0.0" {
t.Errorf("Expected v1.0.0, got %s", result)
}
}

458
internal/distros/nixos.go Normal file
View File

@@ -0,0 +1,458 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/danklinux/internal/deps"
)
func init() {
Register("nixos", "#7EBAE4", FamilyNix, func(config DistroConfig, logChan chan<- string) Distribution {
return NewNixOSDistribution(config, logChan)
})
}
type NixOSDistribution struct {
*BaseDistribution
config DistroConfig
}
func NewNixOSDistribution(config DistroConfig, logChan chan<- string) *NixOSDistribution {
base := NewBaseDistribution(logChan)
return &NixOSDistribution{
BaseDistribution: base,
config: config,
}
}
func (n *NixOSDistribution) GetID() string {
return n.config.ID
}
func (n *NixOSDistribution) GetColorHex() string {
return n.config.ColorHex
}
func (n *NixOSDistribution) GetFamily() DistroFamily {
return n.config.Family
}
func (n *NixOSDistribution) GetPackageManager() PackageManagerType {
return PackageManagerNix
}
func (n *NixOSDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return n.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (n *NixOSDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, n.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, n.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, n.detectGit())
dependencies = append(dependencies, n.detectWindowManager(wm))
dependencies = append(dependencies, n.detectQuickshell())
dependencies = append(dependencies, n.detectXDGPortal())
dependencies = append(dependencies, n.detectPolkitAgent())
dependencies = append(dependencies, n.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, n.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, n.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, n.detectMatugen())
dependencies = append(dependencies, n.detectDgop())
dependencies = append(dependencies, n.detectHyprpicker())
dependencies = append(dependencies, n.detectClipboardTools()...)
return dependencies, nil
}
func (n *NixOSDistribution) detectDMS() deps.Dependency {
status := deps.StatusMissing
// For NixOS, check if quickshell can find the dms config
cmd := exec.Command("qs", "-c", "dms", "--list")
if err := cmd.Run(); err == nil {
status = deps.StatusInstalled
} else if n.packageInstalled("DankMaterialShell") {
// Fallback: check if flake is in profile
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Description: "Desktop Management System configuration (installed as flake)",
Required: true,
}
}
func (n *NixOSDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (n *NixOSDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency {
switch wm {
case deps.WindowManagerHyprland:
status := deps.StatusMissing
description := "Dynamic tiling Wayland compositor"
if n.commandExists("hyprland") || n.commandExists("Hyprland") {
status = deps.StatusInstalled
} else {
description = "Install system-wide: programs.hyprland.enable = true; in configuration.nix"
}
return deps.Dependency{
Name: "hyprland",
Status: status,
Description: description,
Required: true,
}
case deps.WindowManagerNiri:
status := deps.StatusMissing
description := "Scrollable-tiling Wayland compositor"
if n.commandExists("niri") {
status = deps.StatusInstalled
} else {
description = "Install system-wide: environment.systemPackages = [ pkgs.niri ]; in configuration.nix"
}
return deps.Dependency{
Name: "niri",
Status: status,
Description: description,
Required: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
Status: deps.StatusMissing,
Description: "Unknown window manager",
Required: true,
}
}
}
func (n *NixOSDistribution) detectHyprlandTools() []deps.Dependency {
var dependencies []deps.Dependency
tools := []struct {
name string
description string
}{
{"grim", "Screenshot utility for Wayland"},
{"slurp", "Region selection utility for Wayland"},
{"hyprctl", "Hyprland control utility (comes with system Hyprland)"},
{"hyprpicker", "Color picker for Hyprland"},
{"grimblast", "Screenshot script for Hyprland"},
{"jq", "JSON processor"},
}
for _, tool := range tools {
status := deps.StatusMissing
// Special handling for hyprctl - it comes with system hyprland
if tool.name == "hyprctl" {
if n.commandExists("hyprctl") {
status = deps.StatusInstalled
}
} else {
if n.commandExists(tool.name) {
status = deps.StatusInstalled
}
}
dependencies = append(dependencies, deps.Dependency{
Name: tool.name,
Status: status,
Description: tool.description,
Required: true,
})
}
return dependencies
}
func (n *NixOSDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if n.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (n *NixOSDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (n *NixOSDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (n *NixOSDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("nix", "profile", "list")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), pkg)
}
func (n *NixOSDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "nixpkgs#git", Repository: RepoTypeSystem},
"quickshell": {Name: "github:quickshell-mirror/quickshell", Repository: RepoTypeFlake},
"matugen": {Name: "github:InioX/matugen", Repository: RepoTypeFlake},
"dgop": {Name: "github:AvengeMedia/dgop", Repository: RepoTypeFlake},
"dms (DankMaterialShell)": {Name: "github:AvengeMedia/DankMaterialShell", Repository: RepoTypeFlake},
"ghostty": {Name: "nixpkgs#ghostty", Repository: RepoTypeSystem},
"alacritty": {Name: "nixpkgs#alacritty", Repository: RepoTypeSystem},
"cliphist": {Name: "nixpkgs#cliphist", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "nixpkgs#wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "nixpkgs#xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "nixpkgs#mate.mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "nixpkgs#accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "nixpkgs#hyprpicker", Repository: RepoTypeSystem},
}
// Note: Window managers (hyprland/niri) should be installed system-wide on NixOS
// We only install the tools here
switch wm {
case deps.WindowManagerHyprland:
// Skip hyprland itself - should be installed system-wide
packages["grim"] = PackageMapping{Name: "nixpkgs#grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "nixpkgs#slurp", Repository: RepoTypeSystem}
packages["grimblast"] = PackageMapping{Name: "github:hyprwm/contrib#grimblast", Repository: RepoTypeFlake}
packages["jq"] = PackageMapping{Name: "nixpkgs#jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
// Skip niri itself - should be installed system-wide
packages["xwayland-satellite"] = PackageMapping{Name: "nixpkgs#xwayland-satellite", Repository: RepoTypeFlake}
}
return packages
}
func (n *NixOSDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "NixOS prerequisites ready",
IsComplete: false,
LogOutput: "NixOS package manager is ready to use",
}
return nil
}
func (n *NixOSDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := n.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
nixpkgsPkgs, flakePkgs := n.categorizePackages(dependencies, wm, reinstallFlags)
// Phase 2: Nixpkgs Packages
if len(nixpkgsPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d packages from nixpkgs...", len(nixpkgsPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing nixpkgs packages: %s", strings.Join(nixpkgsPkgs, ", ")),
}
if err := n.installNixpkgsPackages(ctx, nixpkgsPkgs, progressChan); err != nil {
return fmt.Errorf("failed to install nixpkgs packages: %w", err)
}
}
// Phase 3: Flake Packages
if len(flakePkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d packages from flakes...", len(flakePkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing flake packages: %s", strings.Join(flakePkgs, ", ")),
}
if err := n.installFlakePackages(ctx, flakePkgs, progressChan); err != nil {
return fmt.Errorf("failed to install flake packages: %w", err)
}
}
// Phase 4: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
if err := n.postInstallConfig(progressChan); err != nil {
return fmt.Errorf("failed to configure system: %w", err)
}
// Phase 5: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (n *NixOSDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool) ([]string, []string) {
nixpkgsPkgs := []string{}
flakePkgs := []string{}
packageMap := n.GetPackageMapping(wm)
for _, dep := range dependencies {
// Skip installed packages unless marked for reinstall
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
n.log(fmt.Sprintf("Warning: No package mapping found for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
nixpkgsPkgs = append(nixpkgsPkgs, pkgInfo.Name)
case RepoTypeFlake:
flakePkgs = append(flakePkgs, pkgInfo.Name)
}
}
return nixpkgsPkgs, flakePkgs
}
func (n *NixOSDistribution) installNixpkgsPackages(ctx context.Context, packages []string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
n.log(fmt.Sprintf("Installing nixpkgs packages: %s", strings.Join(packages, ", ")))
args := []string{"profile", "install"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing nixpkgs packages...",
IsComplete: false,
CommandInfo: fmt.Sprintf("nix %s", strings.Join(args, " ")),
}
cmd := exec.CommandContext(ctx, "nix", args...)
return n.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (n *NixOSDistribution) installFlakePackages(ctx context.Context, packages []string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
n.log(fmt.Sprintf("Installing flake packages: %s", strings.Join(packages, ", ")))
baseProgress := 0.65
progressStep := 0.20 / float64(len(packages))
for i, pkg := range packages {
currentProgress := baseProgress + (float64(i) * progressStep)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: currentProgress,
Step: fmt.Sprintf("Installing flake package %s (%d/%d)...", pkg, i+1, len(packages)),
IsComplete: false,
CommandInfo: fmt.Sprintf("nix profile install %s", pkg),
}
cmd := exec.CommandContext(ctx, "nix", "profile", "install", pkg)
if err := n.runWithProgress(cmd, progressChan, PhaseAURPackages, currentProgress, currentProgress+progressStep); err != nil {
return fmt.Errorf("failed to install flake package %s: %w", pkg, err)
}
}
return nil
}
func (n *NixOSDistribution) postInstallConfig(progressChan chan<- InstallProgressMsg) error {
// For NixOS, DMS is installed as a flake package, so we skip both the binary installation and git clone
// The flake installation handles both the binary and config files correctly
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.95,
Step: "NixOS configuration complete",
IsComplete: false,
LogOutput: "DMS installed via flake - binary and config handled by Nix",
}
return nil
}

View File

@@ -0,0 +1,605 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/danklinux/internal/deps"
)
func init() {
Register("opensuse-tumbleweed", "#73BA25", FamilySUSE, func(config DistroConfig, logChan chan<- string) Distribution {
return NewOpenSUSEDistribution(config, logChan)
})
}
type OpenSUSEDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewOpenSUSEDistribution(config DistroConfig, logChan chan<- string) *OpenSUSEDistribution {
base := NewBaseDistribution(logChan)
return &OpenSUSEDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (o *OpenSUSEDistribution) GetID() string {
return o.config.ID
}
func (o *OpenSUSEDistribution) GetColorHex() string {
return o.config.ColorHex
}
func (o *OpenSUSEDistribution) GetFamily() DistroFamily {
return o.config.Family
}
func (o *OpenSUSEDistribution) GetPackageManager() PackageManagerType {
return PackageManagerZypper
}
func (o *OpenSUSEDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return o.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, o.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, o.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, o.detectGit())
dependencies = append(dependencies, o.detectWindowManager(wm))
dependencies = append(dependencies, o.detectQuickshell())
dependencies = append(dependencies, o.detectXDGPortal())
dependencies = append(dependencies, o.detectPolkitAgent())
dependencies = append(dependencies, o.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, o.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, o.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDgop())
dependencies = append(dependencies, o.detectHyprpicker())
dependencies = append(dependencies, o.detectClipboardTools()...)
return dependencies, nil
}
func (o *OpenSUSEDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (o *OpenSUSEDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
return err == nil
}
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return o.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
// Standard zypper packages
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeSystem},
// Manual builds
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (o *OpenSUSEDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if o.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (o *OpenSUSEDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (o *OpenSUSEDistribution) getPrerequisites() []string {
return []string{
"make",
"unzip",
"gcc",
"gcc-c++",
"cmake",
"ninja",
"pkgconf-pkg-config",
"git",
"qt6-base-devel",
"qt6-declarative-devel",
"qt6-declarative-private-devel",
"qt6-shadertools",
"qt6-shadertools-devel",
"qt6-wayland-devel",
"qt6-waylandclient-private-devel",
"spirv-tools-devel",
"cli11-devel",
"wayland-protocols-devel",
"libgbm-devel",
"libdrm-devel",
"pipewire-devel",
"jemalloc-devel",
"wayland-utils",
"Mesa-libGLESv3-devel",
"pam-devel",
"glib2-devel",
"polkit-devel",
}
}
func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := o.getPrerequisites()
var missingPkgs []string
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
o.log("go not found in PATH, will install go")
missingPkgs = append(missingPkgs, "go")
} else {
o.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
o.log("All prerequisites already installed")
return nil
}
o.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo zypper install -y %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...)
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
o.logError("failed to install prerequisites", err)
o.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w", err)
}
o.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := o.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, manualPkgs := o.categorizePackages(dependencies, wm, reinstallFlags)
// Phase 2: System Packages (Zypper)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install zypper packages: %w", err)
}
}
// Phase 3: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := o.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 4: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 5: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (o *OpenSUSEDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool) ([]string, []string) {
systemPkgs := []string{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := o.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
// Skip installed packages unless marked for reinstall
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
o.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, manualPkgs
}
func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing zypper packages: %s", strings.Join(packages, ", ")))
args := []string{"zypper", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
// installQuickshell overrides the base implementation to set openSUSE-specific CFLAGS
func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
o.log("Installing quickshell from source (with openSUSE-specific build flags)...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning quickshell repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git",
}
var cloneCmd *exec.Cmd
if forceQuickshellGit {
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
// Get latest tag from repository
latestTag := o.getLatestQuickshellTag(ctx)
if latestTag != "" {
o.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag))
cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
o.log("Warning: failed to fetch latest tag, using default branch")
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
}
}
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone quickshell: %w", err)
}
buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.3,
Step: "Configuring quickshell build (with openSUSE flags)...",
IsComplete: false,
CommandInfo: "cmake -B build -S . -G Ninja",
}
// Get optflags from rpm
optflagsCmd := exec.CommandContext(ctx, "rpm", "--eval", "%{optflags}")
optflagsOutput, err := optflagsCmd.Output()
optflags := strings.TrimSpace(string(optflagsOutput))
if err != nil || optflags == "" {
o.log("Warning: Could not get optflags from rpm, using default -O2 -g")
optflags = "-O2 -g"
}
// Set openSUSE-specific CFLAGS
customCFLAGS := fmt.Sprintf("%s -I/usr/include/wayland", optflags)
configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build",
"-DCMAKE_BUILD_TYPE=RelWithDebInfo",
"-DCRASH_REPORTER=off",
"-DCMAKE_CXX_STANDARD=20")
configureCmd.Dir = tmpDir
configureCmd.Env = append(os.Environ(),
"TMPDIR="+cacheDir,
"CFLAGS="+customCFLAGS,
"CXXFLAGS="+customCFLAGS)
o.log(fmt.Sprintf("Using CFLAGS: %s", customCFLAGS))
output, err := configureCmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output))
}
o.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building quickshell (this may take a while)...",
IsComplete: false,
CommandInfo: "cmake --build build",
}
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(),
"TMPDIR="+cacheDir,
"CFLAGS="+customCFLAGS,
"CXXFLAGS="+customCFLAGS)
if err := o.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil {
return fmt.Errorf("failed to build quickshell: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cmake --install build",
}
installCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && cmake --install build", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
o.log("quickshell installed successfully from source")
return nil
}
func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if o.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo zypper install rustup",
}
rustupInstallCmd := execSudoCommand(ctx, sudoPassword, "zypper install -y rustup")
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := o.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
if !o.commandExists("cargo") {
o.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
// InstallManualPackages overrides the base implementation to use openSUSE-specific builds
func (o *OpenSUSEDistribution) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
// Install Rust if needed for matugen
for _, pkg := range packages {
if pkg == "matugen" {
if err := o.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
break
}
}
for _, pkg := range packages {
if pkg == "quickshell" {
if err := o.installQuickshell(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
} else {
// Use the base ManualPackageInstaller for other packages
if err := o.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

115
internal/distros/osinfo.go Normal file
View File

@@ -0,0 +1,115 @@
package distros
import (
"bufio"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"github.com/AvengeMedia/danklinux/internal/errdefs"
)
// DistroInfo contains basic information about a distribution
type DistroInfo struct {
ID string
HexColorCode string
}
// OSInfo contains complete OS information
type OSInfo struct {
Distribution DistroInfo
Version string
VersionID string
PrettyName string
Architecture string
}
// GetOSInfo detects the current OS and returns information about it
func GetOSInfo() (*OSInfo, error) {
if runtime.GOOS != "linux" {
return nil, errdefs.NewCustomError(errdefs.ErrTypeNotLinux, fmt.Sprintf("Only linux is supported, but I found %s", runtime.GOOS))
}
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
}
info := &OSInfo{
Architecture: runtime.GOARCH,
}
file, err := os.Open("/etc/os-release")
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := strings.Trim(parts[1], "\"")
switch key {
case "ID":
config, exists := Registry[value]
if !exists {
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value))
}
info.Distribution = DistroInfo{
ID: value, // Use the actual ID from os-release
HexColorCode: config.ColorHex,
}
case "VERSION_ID", "BUILD_ID":
info.VersionID = value
case "VERSION":
info.Version = value
case "PRETTY_NAME":
info.PrettyName = value
}
}
return info, scanner.Err()
}
// IsUnsupportedDistro checks if a distribution/version combination is supported
func IsUnsupportedDistro(distroID, versionID string) bool {
if !IsDistroSupported(distroID) {
return true
}
if distroID == "ubuntu" {
parts := strings.Split(versionID, ".")
if len(parts) >= 2 {
major, err1 := strconv.Atoi(parts[0])
minor, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil {
return major < 25 || (major == 25 && minor < 4)
}
}
return true
}
if distroID == "debian" {
if versionID == "" {
// debian testing/sid have no version ID
return false
}
versionNum, err := strconv.Atoi(versionID)
if err == nil {
return versionNum < 12
}
return true
}
return false
}

752
internal/distros/ubuntu.go Normal file
View File

@@ -0,0 +1,752 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/danklinux/internal/deps"
)
func init() {
Register("ubuntu", "#E95420", FamilyUbuntu, func(config DistroConfig, logChan chan<- string) Distribution {
return NewUbuntuDistribution(config, logChan)
})
}
type UbuntuDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewUbuntuDistribution(config DistroConfig, logChan chan<- string) *UbuntuDistribution {
base := NewBaseDistribution(logChan)
return &UbuntuDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (u *UbuntuDistribution) GetID() string {
return u.config.ID
}
func (u *UbuntuDistribution) GetColorHex() string {
return u.config.ColorHex
}
func (u *UbuntuDistribution) GetFamily() DistroFamily {
return u.config.Family
}
func (u *UbuntuDistribution) GetPackageManager() PackageManagerType {
return PackageManagerAPT
}
func (u *UbuntuDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return u.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, u.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, u.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, u.detectGit())
dependencies = append(dependencies, u.detectWindowManager(wm))
dependencies = append(dependencies, u.detectQuickshell())
dependencies = append(dependencies, u.detectXDGPortal())
dependencies = append(dependencies, u.detectPolkitAgent())
dependencies = append(dependencies, u.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, u.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, u.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, u.detectMatugen())
dependencies = append(dependencies, u.detectDgop())
dependencies = append(dependencies, u.detectHyprpicker())
dependencies = append(dependencies, u.detectClipboardTools()...)
return dependencies, nil
}
func (u *UbuntuDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if u.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (u *UbuntuDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if u.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (u *UbuntuDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if u.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (u *UbuntuDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if u.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
}
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
// Standard APT packages
"git": {Name: "git", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"},
// Manual builds (niri and quickshell likely not available in Ubuntu repos or PPAs)
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"niri": {Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"ghostty": {Name: "ghostty", Repository: RepoTypeManual, BuildFunc: "installGhostty"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"cliphist": {Name: "cliphist", Repository: RepoTypeManual, BuildFunc: "installCliphist"},
}
switch wm {
case deps.WindowManagerHyprland:
// Use the cppiber PPA for Hyprland
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeManual, BuildFunc: "installXwaylandSatellite"}
}
return packages
}
func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Updating package lists...",
IsComplete: false,
LogOutput: "Updating APT package lists",
}
updateCmd := execSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: "Installing build-essential...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y build-essential",
LogOutput: "Installing build tools",
}
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
// Not installed, install it
cmd := execSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := execSudoCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev")
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.12,
Step: "Prerequisites installation complete",
IsComplete: false,
LogOutput: "Prerequisites successfully installed",
}
return nil
}
func (u *UbuntuDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := u.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, ppaPkgs, manualPkgs := u.categorizePackages(dependencies, wm, reinstallFlags)
// Phase 2: Enable PPA repositories
if len(ppaPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling PPA repositories...",
IsComplete: false,
LogOutput: "Setting up PPA repositories for additional packages",
}
if err := u.enablePPARepos(ctx, ppaPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable PPA repositories: %w", err)
}
}
// Phase 3: System Packages (APT)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := u.installAPTPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install APT packages: %w", err)
}
}
// Phase 4: PPA Packages
ppaPkgNames := u.extractPackageNames(ppaPkgs)
if len(ppaPkgNames) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, // Reusing AUR phase for PPA
Progress: 0.65,
Step: fmt.Sprintf("Installing %d PPA packages...", len(ppaPkgNames)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing PPA packages: %s", strings.Join(ppaPkgNames, ", ")),
}
if err := u.installPPAPackages(ctx, ppaPkgNames, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install PPA packages: %w", err)
}
}
// Phase 5: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.80,
Step: "Installing build dependencies...",
IsComplete: false,
LogOutput: "Installing build tools for manual compilation",
}
if err := u.installBuildDependencies(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install build dependencies: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := u.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 6: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (u *UbuntuDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool) ([]string, []PackageMapping, []string) {
systemPkgs := []string{}
ppaPkgs := []PackageMapping{}
manualPkgs := []string{}
packageMap := u.GetPackageMapping(wm)
for _, dep := range dependencies {
// Skip installed packages unless marked for reinstall
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
u.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypePPA:
ppaPkgs = append(ppaPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, ppaPkgs, manualPkgs
}
func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
installPPACmd := execSudoCommand(ctx, sudoPassword,
"apt-get install -y software-properties-common")
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
return fmt.Errorf("failed to install software-properties-common: %w", err)
}
for _, pkg := range ppaPkgs {
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
u.log(fmt.Sprintf("Enabling PPA repository: %s", pkg.RepoURL))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.20,
Step: fmt.Sprintf("Enabling PPA repo %s...", pkg.RepoURL),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
}
cmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
return fmt.Errorf("failed to enable PPA repo %s: %w", pkg.RepoURL, err)
}
u.log(fmt.Sprintf("PPA repo %s enabled successfully", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true
}
}
if len(enabledRepos) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Updating package lists...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get update",
}
updateCmd := execSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
}
}
return nil
}
func (u *UbuntuDistribution) installAPTPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
u.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (u *UbuntuDistribution) installPPAPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
u.log(fmt.Sprintf("Installing PPA packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing PPA packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
}
func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
buildDeps := make(map[string]bool)
for _, pkg := range manualPkgs {
switch pkg {
case "niri":
buildDeps["curl"] = true
buildDeps["libxkbcommon-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["libudev-dev"] = true
buildDeps["libinput-dev"] = true
buildDeps["libdisplay-info-dev"] = true
buildDeps["libpango1.0-dev"] = true
buildDeps["libcairo-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libc6-dev"] = true
buildDeps["clang"] = true
buildDeps["libseat-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["alacritty"] = true
buildDeps["fuzzel"] = true
buildDeps["libxcb-cursor-dev"] = true
case "quickshell":
buildDeps["qt6-base-dev"] = true
buildDeps["qt6-base-private-dev"] = true
buildDeps["qt6-declarative-dev"] = true
buildDeps["qt6-declarative-private-dev"] = true
buildDeps["qt6-wayland-dev"] = true
buildDeps["qt6-wayland-private-dev"] = true
buildDeps["qt6-tools-dev"] = true
buildDeps["libqt6svg6-dev"] = true
buildDeps["qt6-shadertools-dev"] = true
buildDeps["spirv-tools"] = true
buildDeps["libcli11-dev"] = true
buildDeps["libjemalloc-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["wayland-protocols"] = true
buildDeps["libdrm-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["libegl-dev"] = true
buildDeps["libgles2-mesa-dev"] = true
buildDeps["libgl1-mesa-dev"] = true
buildDeps["libxcb1-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libpam0g-dev"] = true
case "ghostty":
buildDeps["curl"] = true
buildDeps["libgtk-4-dev"] = true
buildDeps["libadwaita-1-dev"] = true
case "matugen":
buildDeps["curl"] = true
case "cliphist":
// Go will be installed separately with PPA
}
}
for _, pkg := range manualPkgs {
switch pkg {
case "niri", "matugen":
if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "ghostty":
if err := u.installZig(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Zig: %w", err)
}
case "cliphist", "dgop":
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
}
}
}
if len(buildDeps) == 0 {
return nil
}
depList := make([]string, 0, len(buildDeps))
for dep := range buildDeps {
depList = append(depList, dep)
}
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := execSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := execSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := u.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
// Verify cargo is now available
if !u.commandExists("cargo") {
u.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
func (u *UbuntuDistribution) installZig(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("zig") {
return nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
zigUrl := "https://ziglang.org/download/0.11.0/zig-linux-x86_64-0.11.0.tar.xz"
zigTmp := filepath.Join(cacheDir, "zig.tar.xz")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", zigUrl, "-o", zigTmp)
if err := u.runWithProgress(downloadCmd, progressChan, PhaseSystemPackages, 0.84, 0.85); err != nil {
return fmt.Errorf("failed to download Zig: %w", err)
}
extractCmd := execSudoCommand(ctx, sudoPassword,
fmt.Sprintf("tar -xf %s -C /opt/", zigTmp))
if err := u.runWithProgress(extractCmd, progressChan, PhaseSystemPackages, 0.85, 0.86); err != nil {
return fmt.Errorf("failed to extract Zig: %w", err)
}
linkCmd := execSudoCommand(ctx, sudoPassword,
"ln -sf /opt/zig-linux-x86_64-0.11.0/zig /usr/local/bin/zig")
return u.runWithProgress(linkCmd, progressChan, PhaseSystemPackages, 0.86, 0.87)
}
func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("go") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.87,
Step: "Adding Go PPA repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
}
addPPACmd := execSudoCommand(ctx, sudoPassword,
"add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.88,
Step: "Updating package lists...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get update",
}
updateCmd := execSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.89,
Step: "Installing Go...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := execSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
}
func (u *UbuntuDistribution) installGhosttyUbuntu(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
u.log("Installing Ghostty using Ubuntu installer script...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Running Ghostty Ubuntu installer...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | sudo bash",
LogOutput: "Installing Ghostty using pre-built Ubuntu package",
}
installCmd := execSudoCommand(ctx, sudoPassword,
"/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)\"")
if err := u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.9); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
u.log("Ghostty installed successfully using Ubuntu installer")
return nil
}
// Override InstallManualPackages for Ubuntu to handle Ubuntu-specific installations
func (u *UbuntuDistribution) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
u.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
switch pkg {
case "ghostty":
if err := u.installGhosttyUbuntu(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
default:
// Use the base ManualPackageInstaller for other packages
if err := u.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

438
internal/dms/app.go Normal file
View File

@@ -0,0 +1,438 @@
//go:build !distro_binary
package dms
import (
"os/exec"
"strings"
"github.com/AvengeMedia/danklinux/internal/deps"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateUpdate
StateUpdatePassword
StateUpdateProgress
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateGreeterMenu
StateGreeterCompositorSelect
StateGreeterPassword
StateGreeterInstalling
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
updateDeps []DependencyInfo
selectedUpdateDep int
updateToggles map[string]bool
updateProgressChan chan updateProgressMsg
updateProgress updateProgressMsg
updateLogs []string
sudoPassword string
passwordInput string
passwordError string
// Window manager states
hyprlandInstalled bool
niriInstalled bool
selectedGreeterItem int
greeterInstallChan chan greeterProgressMsg
greeterProgress greeterProgressMsg
greeterLogs []string
greeterPasswordInput string
greeterPasswordError string
greeterSudoPassword string
greeterCompositors []string
greeterSelectedComp int
greeterChosenCompositor string
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
dependencies := detector.GetInstalledComponents()
// Use the proper detection method for both window managers
hyprlandInstalled, niriInstalled, err := detector.GetWindowManagerStatus()
if err != nil {
// Fallback to false if detection fails
hyprlandInstalled = false
niriInstalled = false
}
updateToggles := make(map[string]bool)
for _, dep := range dependencies {
if dep.Name == "dms (DankMaterialShell)" && dep.Status == deps.StatusNeedsUpdate {
updateToggles[dep.Name] = true
break
}
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
updateToggles: updateToggles,
updateDeps: dependencies,
updateProgressChan: make(chan updateProgressMsg, 100),
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
greeterInstallChan: make(chan greeterProgressMsg, 100),
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{
{Label: "Update", Action: StateUpdate},
}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
// Greeter management
items = append(items, MenuItem{Label: "Greeter", Action: StateGreeterMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
// Check for both -c and -p flag patterns since quickshell can be started either way
// -c dms: config name mode
// -p <path>/dms: path mode (used when installed via system packages)
cmd := exec.Command("pgrep", "-f", "qs.*dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case shellStartedMsg:
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
return m, nil
case updateProgressMsg:
m.updateProgress = msg
if msg.logOutput != "" {
m.updateLogs = append(m.updateLogs, msg.logOutput)
}
return m, m.waitForProgress()
case updateCompleteMsg:
m.updateProgress.complete = true
m.updateProgress.err = msg.err
m.dependencies = m.detector.GetInstalledComponents()
m.updateDeps = m.dependencies
m.menuItems = m.buildMenuItems()
// Restart shell if update was successful and shell is running
if msg.err == nil && m.isShellRunning() {
restartShell()
}
return m, nil
case greeterProgressMsg:
m.greeterProgress = msg
if msg.logOutput != "" {
m.greeterLogs = append(m.greeterLogs, msg.logOutput)
}
return m, m.waitForGreeterProgress()
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case greeterPasswordValidMsg:
if msg.valid {
m.greeterSudoPassword = msg.password
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
m.state = StateGreeterInstalling
m.greeterProgress = greeterProgressMsg{step: "Starting greeter installation..."}
m.greeterLogs = []string{}
return m, tea.Batch(m.performGreeterInstall(), m.waitForGreeterProgress())
} else {
m.greeterPasswordError = "Incorrect password. Please try again."
m.greeterPasswordInput = ""
}
return m, nil
case passwordValidMsg:
if msg.valid {
m.sudoPassword = msg.password
m.passwordInput = ""
m.passwordError = ""
m.state = StateUpdateProgress
m.updateProgress = updateProgressMsg{progress: 0.0, step: "Starting update..."}
m.updateLogs = []string{}
return m, tea.Batch(m.performUpdate(), m.waitForProgress())
} else {
m.passwordError = "Incorrect password. Please try again."
m.passwordInput = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateUpdate:
return m.updateUpdateView(msg)
case StateUpdatePassword:
return m.updatePasswordView(msg)
case StateUpdateProgress:
return m.updateProgressView(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateGreeterMenu:
return m.updateGreeterMenu(msg)
case StateGreeterCompositorSelect:
return m.updateGreeterCompositorSelect(msg)
case StateGreeterPassword:
return m.updateGreeterPasswordView(msg)
case StateGreeterInstalling:
return m.updateGreeterInstalling(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
type updateProgressMsg struct {
progress float64
step string
complete bool
err error
logOutput string
}
type updateCompleteMsg struct {
err error
}
type passwordValidMsg struct {
password string
valid bool
}
type greeterProgressMsg struct {
step string
complete bool
err error
logOutput string
}
type greeterPasswordValidMsg struct {
password string
valid bool
}
func (m Model) waitForProgress() tea.Cmd {
return func() tea.Msg {
return <-m.updateProgressChan
}
}
func (m Model) waitForGreeterProgress() tea.Cmd {
return func() tea.Msg {
return <-m.greeterInstallChan
}
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateUpdate:
return m.renderUpdateView()
case StateUpdatePassword:
return m.renderPasswordView()
case StateUpdateProgress:
return m.renderProgressView()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateGreeterMenu:
return m.renderGreeterMenu()
case StateGreeterCompositorSelect:
return m.renderGreeterCompositorSelect()
case StateGreeterPassword:
return m.renderGreeterPasswordView()
case StateGreeterInstalling:
return m.renderGreeterInstalling()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

261
internal/dms/app_distro.go Normal file
View File

@@ -0,0 +1,261 @@
//go:build distro_binary
package dms
import (
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
// Window manager states
hyprlandInstalled bool
niriInstalled bool
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
dependencies := detector.GetInstalledComponents()
// Use the proper detection method for both window managers
hyprlandInstalled, niriInstalled, err := detector.GetWindowManagerStatus()
if err != nil {
// Fallback to false if detection fails
hyprlandInstalled = false
niriInstalled = false
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
cmd := exec.Command("pgrep", "-f", "qs -c dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

167
internal/dms/detector.go Normal file
View File

@@ -0,0 +1,167 @@
package dms
import (
"context"
"os"
"os/exec"
"github.com/AvengeMedia/danklinux/internal/config"
"github.com/AvengeMedia/danklinux/internal/deps"
"github.com/AvengeMedia/danklinux/internal/distros"
)
type Detector struct {
homeDir string
distribution distros.Distribution
}
func (d *Detector) GetDistribution() distros.Distribution {
return d.distribution
}
func NewDetector() (*Detector, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
logChan := make(chan string, 100)
go func() {
for range logChan {
}
}()
osInfo, err := distros.GetOSInfo()
if err != nil {
return nil, err
}
dist, err := distros.NewDistribution(osInfo.Distribution.ID, logChan)
if err != nil {
return nil, err
}
return &Detector{
homeDir: homeDir,
distribution: dist,
}, nil
}
func (d *Detector) IsDMSInstalled() bool {
_, err := config.LocateDMSConfig()
return err == nil
}
func (d *Detector) GetDependencyStatus() ([]deps.Dependency, error) {
hyprlandDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerHyprland)
if err != nil {
return nil, err
}
niriDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerNiri)
if err != nil {
return nil, err
}
// Combine dependencies and deduplicate
depMap := make(map[string]deps.Dependency)
for _, dep := range hyprlandDeps {
depMap[dep.Name] = dep
}
for _, dep := range niriDeps {
// If dependency already exists, keep the one that's installed or needs update
if existing, exists := depMap[dep.Name]; exists {
if dep.Status > existing.Status {
depMap[dep.Name] = dep
}
} else {
depMap[dep.Name] = dep
}
}
// Convert map back to slice
var allDeps []deps.Dependency
for _, dep := range depMap {
allDeps = append(allDeps, dep)
}
return allDeps, nil
}
func (d *Detector) GetWindowManagerStatus() (bool, bool, error) {
// Reuse the existing command detection logic from BaseDistribution
// Since all distros embed BaseDistribution, we can access it via interface
type CommandChecker interface {
CommandExists(string) bool
}
checker, ok := d.distribution.(CommandChecker)
if !ok {
// Fallback to direct command check if interface not available
hyprlandInstalled := d.commandExists("hyprland") || d.commandExists("Hyprland")
niriInstalled := d.commandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
hyprlandInstalled := checker.CommandExists("hyprland") || checker.CommandExists("Hyprland")
niriInstalled := checker.CommandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
func (d *Detector) commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func (d *Detector) GetInstalledComponents() []DependencyInfo {
dependencies, err := d.GetDependencyStatus()
if err != nil {
return []DependencyInfo{}
}
isNixOS := d.isNixOS()
var components []DependencyInfo
for _, dep := range dependencies {
// On NixOS, filter out the window managers themselves but keep their components
if isNixOS && (dep.Name == "hyprland" || dep.Name == "niri") {
continue
}
components = append(components, DependencyInfo{
Name: dep.Name,
Status: dep.Status,
Description: dep.Description,
Required: dep.Required,
})
}
return components
}
func (d *Detector) isNixOS() bool {
_, err := os.Stat("/etc/nixos")
if err == nil {
return true
}
// Alternative check
if _, err := os.Stat("/nix/store"); err == nil {
// Also check for nixos-version command
if d.commandExists("nixos-version") {
return true
}
}
return false
}
type DependencyInfo struct {
Name string
Status deps.DependencyStatus
Description string
Required bool
}

View File

@@ -0,0 +1,54 @@
package dms
import (
"os/exec"
"time"
"github.com/AvengeMedia/danklinux/internal/log"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateShellView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
default:
return m, tea.Quit
}
return m, nil
}
func (m Model) updateAboutView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
if msg.String() == "esc" {
m.state = StateMainMenu
} else {
return m, tea.Quit
}
}
return m, nil
}
func terminateShell() {
patterns := []string{"dms run", "qs -c dms"}
for _, pattern := range patterns {
cmd := exec.Command("pkill", "-f", pattern)
cmd.Run()
}
}
func startShellDaemon() {
cmd := exec.Command("dms", "run", "-d")
if err := cmd.Start(); err != nil {
log.Errorf("Error starting daemon: %v", err)
}
}
func restartShell() {
terminateShell()
time.Sleep(500 * time.Millisecond)
startShellDaemon()
}

View File

@@ -0,0 +1,391 @@
//go:build !distro_binary
package dms
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/AvengeMedia/danklinux/internal/deps"
"github.com/AvengeMedia/danklinux/internal/distros"
"github.com/AvengeMedia/danklinux/internal/greeter"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateUpdateView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
filteredDeps := m.getFilteredDeps()
maxIndex := len(filteredDeps) - 1
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedUpdateDep > 0 {
m.selectedUpdateDep--
}
case "down", "j":
if m.selectedUpdateDep < maxIndex {
m.selectedUpdateDep++
}
case " ":
if dep := m.getDepAtVisualIndex(m.selectedUpdateDep); dep != nil {
m.updateToggles[dep.Name] = !m.updateToggles[dep.Name]
}
case "enter":
hasSelected := false
for _, toggle := range m.updateToggles {
if toggle {
hasSelected = true
break
}
}
if !hasSelected {
m.state = StateMainMenu
return m, nil
}
m.state = StateUpdatePassword
m.passwordInput = ""
m.passwordError = ""
return m, nil
}
return m, nil
}
func (m Model) updatePasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateUpdate
m.passwordInput = ""
m.passwordError = ""
return m, nil
case "enter":
if m.passwordInput == "" {
return m, nil
}
return m, m.validatePassword(m.passwordInput)
case "backspace":
if len(m.passwordInput) > 0 {
m.passwordInput = m.passwordInput[:len(m.passwordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.passwordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateProgressView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.updateProgress.complete {
m.state = StateMainMenu
m.updateProgress = updateProgressMsg{}
m.updateLogs = []string{}
}
}
return m, nil
}
func (m Model) validatePassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: password, valid: true}
}
}
func (m Model) performUpdate() tea.Cmd {
var depsToUpdate []deps.Dependency
for _, depInfo := range m.updateDeps {
if m.updateToggles[depInfo.Name] {
depsToUpdate = append(depsToUpdate, deps.Dependency{
Name: depInfo.Name,
Status: depInfo.Status,
Description: depInfo.Description,
Required: depInfo.Required,
})
}
}
if len(depsToUpdate) == 0 {
return func() tea.Msg {
return updateCompleteMsg{err: nil}
}
}
wm := deps.WindowManagerHyprland
if m.niriInstalled {
wm = deps.WindowManagerNiri
}
sudoPassword := m.sudoPassword
reinstallFlags := make(map[string]bool)
for name, toggled := range m.updateToggles {
if toggled {
reinstallFlags[name] = true
}
}
distribution := m.detector.GetDistribution()
progressChan := m.updateProgressChan
return func() tea.Msg {
installerChan := make(chan distros.InstallProgressMsg, 100)
go func() {
ctx := context.Background()
err := distribution.InstallPackages(ctx, depsToUpdate, wm, sudoPassword, reinstallFlags, installerChan)
close(installerChan)
if err != nil {
progressChan <- updateProgressMsg{complete: true, err: err}
} else {
progressChan <- updateProgressMsg{complete: true}
}
}()
go func() {
for msg := range installerChan {
progressChan <- updateProgressMsg{
progress: msg.Progress,
step: msg.Step,
complete: msg.IsComplete,
err: msg.Error,
logOutput: msg.LogOutput,
}
}
}()
return nil
}
}
func (m Model) updateGreeterMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
greeterMenuItems := []string{"Install Greeter"}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedGreeterItem > 0 {
m.selectedGreeterItem--
}
case "down", "j":
if m.selectedGreeterItem < len(greeterMenuItems)-1 {
m.selectedGreeterItem++
}
case "enter", " ":
if m.selectedGreeterItem == 0 {
compositors := greeter.DetectCompositors()
if len(compositors) == 0 {
return m, nil
}
m.greeterCompositors = compositors
if len(compositors) > 1 {
m.state = StateGreeterCompositorSelect
m.greeterSelectedComp = 0
return m, nil
} else {
m.greeterChosenCompositor = compositors[0]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
}
}
return m, nil
}
func (m Model) updateGreeterCompositorSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
return m, nil
case "up", "k":
if m.greeterSelectedComp > 0 {
m.greeterSelectedComp--
}
case "down", "j":
if m.greeterSelectedComp < len(m.greeterCompositors)-1 {
m.greeterSelectedComp++
}
case "enter", " ":
m.greeterChosenCompositor = m.greeterCompositors[m.greeterSelectedComp]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
return m, nil
}
func (m Model) updateGreeterPasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
case "enter":
if m.greeterPasswordInput == "" {
return m, nil
}
return m, m.validateGreeterPassword(m.greeterPasswordInput)
case "backspace":
if len(m.greeterPasswordInput) > 0 {
m.greeterPasswordInput = m.greeterPasswordInput[:len(m.greeterPasswordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.greeterPasswordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateGreeterInstalling(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.greeterProgress.complete {
m.state = StateMainMenu
m.greeterProgress = greeterProgressMsg{}
m.greeterLogs = []string{}
}
}
return m, nil
}
func (m Model) performGreeterInstall() tea.Cmd {
progressChan := m.greeterInstallChan
sudoPassword := m.greeterSudoPassword
compositor := m.greeterChosenCompositor
return func() tea.Msg {
go func() {
logFunc := func(msg string) {
progressChan <- greeterProgressMsg{step: msg, logOutput: msg}
}
progressChan <- greeterProgressMsg{step: "Checking greetd installation..."}
if err := performGreeterInstallSteps(progressChan, logFunc, sudoPassword, compositor); err != nil {
progressChan <- greeterProgressMsg{step: "Installation failed", complete: true, err: err}
return
}
progressChan <- greeterProgressMsg{step: "Installation complete", complete: true}
}()
return nil
}
}
func (m Model) validateGreeterPassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return greeterPasswordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: password, valid: true}
}
}
func performGreeterInstallSteps(progressChan chan greeterProgressMsg, logFunc func(string), sudoPassword string, compositor string) error {
if err := greeter.EnsureGreetdInstalled(logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Detecting DMS installation..."}
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
logFunc(fmt.Sprintf("✓ Found DMS at: %s", dmsPath))
logFunc(fmt.Sprintf("✓ Selected compositor: %s", compositor))
progressChan <- greeterProgressMsg{step: "Copying greeter files..."}
if err := greeter.CopyGreeterFiles(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Configuring greetd..."}
if err := greeter.ConfigureGreetd(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Synchronizing DMS configurations..."}
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, sudoPassword); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,61 @@
//go:build !distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateUpdate:
m.state = StateUpdate
m.selectedUpdateDep = 0
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateGreeterMenu:
m.state = StateGreeterMenu
m.selectedGreeterItem = 0
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -0,0 +1,55 @@
//go:build distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -0,0 +1,339 @@
package dms
import (
"strings"
"github.com/AvengeMedia/danklinux/internal/plugins"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updatePluginsMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedPluginsMenuItem > 0 {
m.selectedPluginsMenuItem--
}
case "down", "j":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems)-1 {
m.selectedPluginsMenuItem++
}
case "enter", " ":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems) {
selectedAction := m.pluginsMenuItems[m.selectedPluginsMenuItem].Action
switch selectedAction {
case StatePluginsBrowse:
m.state = StatePluginsBrowse
m.pluginsLoading = true
m.pluginsError = ""
m.pluginsList = nil
return m, loadPlugins
case StatePluginsInstalled:
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
m.installedPluginsList = nil
return m, loadInstalledPlugins
}
}
}
return m, nil
}
func (m Model) updatePluginsBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "up", "k":
if m.selectedPluginIndex > 0 {
m.selectedPluginIndex--
}
case "down", "j":
if m.selectedPluginIndex < len(m.filteredPluginsList)-1 {
m.selectedPluginIndex++
}
case "enter", " ":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
m.state = StatePluginDetail
}
case "/":
m.state = StatePluginSearch
m.pluginSearchQuery = ""
}
return m, nil
}
func (m Model) updatePluginDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
case "i":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
plugin := m.filteredPluginsList[m.selectedPluginIndex]
installed := m.pluginInstallStatus[plugin.Name]
if !installed {
return m, installPlugin(plugin)
}
}
}
return m, nil
}
func (m Model) updatePluginSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "enter":
m.state = StatePluginsBrowse
m.filterPlugins()
case "backspace":
if len(m.pluginSearchQuery) > 0 {
m.pluginSearchQuery = m.pluginSearchQuery[:len(m.pluginSearchQuery)-1]
}
default:
if len(msg.String()) == 1 {
m.pluginSearchQuery += msg.String()
}
}
return m, nil
}
func (m *Model) filterPlugins() {
if m.pluginSearchQuery == "" {
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
return
}
rawPlugins := make([]plugins.Plugin, len(m.pluginsList))
for i, p := range m.pluginsList {
rawPlugins[i] = plugins.Plugin{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
}
}
searchResults := plugins.FuzzySearch(m.pluginSearchQuery, rawPlugins)
searchResults = plugins.SortByFirstParty(searchResults)
filtered := make([]pluginInfo, len(searchResults))
for i, p := range searchResults {
filtered[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = filtered
m.selectedPluginIndex = 0
}
type pluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
func loadPlugins() tea.Msg {
registry, err := plugins.NewRegistry()
if err != nil {
return pluginsLoadedMsg{err: err}
}
pluginList, err := registry.List()
if err != nil {
return pluginsLoadedMsg{err: err}
}
return pluginsLoadedMsg{plugins: pluginList}
}
func (m *Model) updatePluginInstallStatus() {
manager, err := plugins.NewManager()
if err != nil {
return
}
for _, plugin := range m.pluginsList {
p := plugins.Plugin{ID: plugin.ID}
installed, err := manager.IsInstalled(p)
if err == nil {
m.pluginInstallStatus[plugin.Name] = installed
}
}
}
func (m Model) updatePluginsInstalled(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
case "up", "k":
if m.selectedInstalledIndex > 0 {
m.selectedInstalledIndex--
}
case "down", "j":
if m.selectedInstalledIndex < len(m.installedPluginsList)-1 {
m.selectedInstalledIndex++
}
case "enter", " ":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
m.state = StatePluginInstalledDetail
}
}
return m, nil
}
func (m Model) updatePluginInstalledDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsInstalled
case "u":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, uninstallPlugin(plugin)
}
}
return m, nil
}
type installedPluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
type pluginUninstalledMsg struct {
pluginName string
err error
}
type pluginInstalledMsg struct {
pluginName string
err error
}
func loadInstalledPlugins() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
registry, err := plugins.NewRegistry()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
installedNames, err := manager.ListInstalled()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
allPlugins, err := registry.List()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
var installed []plugins.Plugin
for _, id := range installedNames {
for _, p := range allPlugins {
if p.ID == id {
installed = append(installed, p)
break
}
}
}
installed = plugins.SortByFirstParty(installed)
return installedPluginsLoadedMsg{plugins: installed}
}
func installPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Install(p); err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginInstalledMsg{pluginName: plugin.Name}
}
}
func uninstallPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Uninstall(p); err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginUninstalledMsg{pluginName: plugin.Name}
}
}

View File

@@ -0,0 +1,367 @@
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderPluginsMenu() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Plugins"))
b.WriteString("\n\n")
for i, item := range m.pluginsMenuItems {
if i == m.selectedPluginsMenuItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Select | Esc: Back | q: Quit"))
return b.String()
}
func (m Model) renderPluginsBrowse() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Browse Plugins"))
b.WriteString("\n\n")
if m.pluginsLoading {
b.WriteString(normalStyle.Render("Fetching plugins from registry..."))
} else if m.pluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.pluginsError)))
} else if len(m.filteredPluginsList) == 0 {
if m.pluginSearchQuery != "" {
b.WriteString(normalStyle.Render(fmt.Sprintf("No plugins match '%s'", m.pluginSearchQuery)))
} else {
b.WriteString(normalStyle.Render("No plugins found in registry."))
}
} else {
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
for i, plugin := range m.filteredPluginsList {
installed := m.pluginInstallStatus[plugin.Name]
installMarker := ""
if installed {
installMarker = " [Installed]"
}
if i == m.selectedPluginIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.pluginsLoading || m.pluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: View/Install | /: Search | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.selectedPluginIndex >= len(m.filteredPluginsList) {
return "No plugin selected"
}
plugin := m.filteredPluginsList[m.selectedPluginIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
installed := m.pluginInstallStatus[plugin.Name]
if installed {
b.WriteString(labelStyle.Render("Status: "))
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(installedStyle.Render("Installed"))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if installed {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("i: Install | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginSearch() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(titleStyle.Render("Search Plugins"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Query: "))
b.WriteString(titleStyle.Render(m.pluginSearchQuery + "▌"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Enter: Search | Esc: Cancel"))
return b.String()
}
func (m Model) renderPluginsInstalled() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Installed Plugins"))
b.WriteString("\n\n")
if m.installedPluginsLoading {
b.WriteString(normalStyle.Render("Loading installed plugins..."))
} else if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
} else if len(m.installedPluginsList) == 0 {
b.WriteString(normalStyle.Render("No plugins installed."))
} else {
for i, plugin := range m.installedPluginsList {
if i == m.selectedInstalledIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.installedPluginsLoading || m.installedPluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Details | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginInstalledDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
if m.selectedInstalledIndex >= len(m.installedPluginsList) {
return "No plugin selected"
}
plugin := m.installedPluginsList[m.selectedInstalledIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("u: Uninstall | Esc: Back | q: Quit"))
return b.String()
}
func wrapText(text string, width int) string {
words := strings.Fields(text)
if len(words) == 0 {
return text
}
var lines []string
currentLine := words[0]
for _, word := range words[1:] {
if len(currentLine)+1+len(word) <= width {
currentLine += " " + word
} else {
lines = append(lines, currentLine)
currentLine = word
}
}
lines = append(lines, currentLine)
return strings.Join(lines, "\n")
}

View File

@@ -0,0 +1,149 @@
package dms
import (
"fmt"
"strings"
"github.com/AvengeMedia/danklinux/internal/tui"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderMainMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("dms"))
b.WriteString("\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range m.menuItems {
if i == m.selectedItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, q/Esc: Exit"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderShellView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Shell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Opening interactive shell..."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("This will launch a shell with DMS environment loaded."))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Press any key to launch shell, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderAboutView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("About DankMaterialShell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render(fmt.Sprintf("DMS Management Interface %s", m.version)))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("DankMaterialShell is a comprehensive desktop environment"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("built around Quickshell, providing a modern Material Design"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("experience for Wayland compositors."))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Components:"))
b.WriteString("\n")
for _, dep := range m.dependencies {
status := "✗"
if dep.Status == 1 {
status = "✓"
}
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s %s", status, dep.Name)))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Esc: Back to main menu"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderBanner() string {
theme := tui.TerminalTheme()
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true).
MarginBottom(1)
return titleStyle.Render(logo)
}

View File

@@ -0,0 +1,529 @@
//go:build !distro_binary
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderUpdateView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Update Dependencies"))
b.WriteString("\n")
if len(m.updateDeps) == 0 {
b.WriteString("Loading dependencies...\n")
return b.String()
}
categories := m.categorizeDependencies()
currentIndex := 0
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if !exists || len(deps) == 0 {
continue
}
categoryStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7060ac")).
Bold(true).
MarginTop(1)
b.WriteString(categoryStyle.Render(category + ":"))
b.WriteString("\n")
for _, dep := range deps {
var statusText, icon, reinstallMarker string
var style lipgloss.Style
if m.updateToggles[dep.Name] {
reinstallMarker = "🔄 "
if dep.Status == 0 {
statusText = "Will be installed"
} else {
statusText = "Will be upgraded"
}
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
} else {
switch dep.Status {
case 1:
icon = "✓"
statusText = "Installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
case 0:
icon = "○"
statusText = "Not installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
case 2:
icon = "△"
statusText = "Needs update"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
case 3:
icon = "!"
statusText = "Needs reinstall"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
}
}
line := fmt.Sprintf("%s%s%-25s %s", reinstallMarker, icon, dep.Name, statusText)
if currentIndex == m.selectedUpdateDep {
line = "▶ " + line
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7060ac")).Bold(true)
b.WriteString(selectedStyle.Render(line))
} else {
line = " " + line
b.WriteString(style.Render(line))
}
b.WriteString("\n")
currentIndex++
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Space: Toggle, Enter: Update Selected, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Package installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.passwordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.passwordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.passwordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderProgressView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Updating Packages"))
b.WriteString("\n\n")
if !m.updateProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.updateProgress.step))
b.WriteString("\n\n")
progressBar := fmt.Sprintf("[%s%s] %.0f%%",
strings.Repeat("█", int(m.updateProgress.progress*30)),
strings.Repeat("░", 30-int(m.updateProgress.progress*30)),
m.updateProgress.progress*100)
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render(progressBar))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Live Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 8
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.updateProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Update failed: %v", m.updateProgress.err)))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Error Logs:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 15
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.updateProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Update complete!"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) getFilteredDeps() []DependencyInfo {
categories := m.categorizeDependencies()
var filtered []DependencyInfo
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if exists {
filtered = append(filtered, deps...)
}
}
return filtered
}
func (m Model) getDepAtVisualIndex(index int) *DependencyInfo {
filtered := m.getFilteredDeps()
if index >= 0 && index < len(filtered) {
return &filtered[index]
}
return nil
}
func (m Model) renderGreeterPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Greeter installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.greeterPasswordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.greeterPasswordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.greeterPasswordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterCompositorSelect() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Select Compositor"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Multiple compositors detected. Choose which one to use for the greeter:"))
b.WriteString("\n\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
for i, comp := range m.greeterCompositors {
if i == m.greeterSelectedComp {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", comp)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", comp)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Greeter Management"))
b.WriteString("\n")
greeterMenuItems := []string{"Install Greeter"}
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range greeterMenuItems {
if i == m.selectedGreeterItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterInstalling() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Installing Greeter"))
b.WriteString("\n\n")
if !m.greeterProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.greeterProgress.step))
b.WriteString("\n\n")
if len(m.greeterLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 10
startIdx := 0
if len(m.greeterLogs) > maxLines {
startIdx = len(m.greeterLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.greeterLogs); i++ {
if m.greeterLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.greeterLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.greeterProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Installation failed: %v", m.greeterProgress.err)))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.greeterProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Greeter installation complete!"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("To test the greeter, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl start greetd"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("To enable on boot, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl enable --now greetd"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) categorizeDependencies() map[string][]DependencyInfo {
categories := map[string][]DependencyInfo{
"Shell": {},
"Shared Components": {},
"Hyprland Components": {},
"Niri Components": {},
}
excludeList := map[string]bool{
"git": true,
"polkit-agent": true,
"jq": true,
"xdg-desktop-portal": true,
"xdg-desktop-portal-wlr": true,
"xdg-desktop-portal-hyprland": true,
"xdg-desktop-portal-gtk": true,
}
for _, dep := range m.updateDeps {
if excludeList[dep.Name] {
continue
}
switch dep.Name {
case "dms (DankMaterialShell)", "quickshell":
categories["Shell"] = append(categories["Shell"], dep)
case "hyprland", "grim", "slurp", "hyprctl", "grimblast":
categories["Hyprland Components"] = append(categories["Hyprland Components"], dep)
case "niri":
categories["Niri Components"] = append(categories["Niri Components"], dep)
case "kitty", "alacritty", "ghostty", "hyprpicker":
categories["Shared Components"] = append(categories["Shared Components"], dep)
default:
categories["Shared Components"] = append(categories["Shared Components"], dep)
}
}
return categories
}

View File

@@ -0,0 +1,65 @@
package errdefs
type ErrorType int
const (
ErrTypeNotLinux ErrorType = iota
ErrTypeInvalidArchitecture
ErrTypeUnsupportedDistribution
ErrTypeUnsupportedVersion
ErrTypeUpdateCancelled
ErrTypeNoUpdateNeeded
ErrTypeInvalidTemperature
ErrTypeInvalidGamma
ErrTypeInvalidLocation
ErrTypeInvalidManualTimes
ErrTypeNoWaylandDisplay
ErrTypeNoGammaControl
ErrTypeNotInitialized
ErrTypeSecretPromptCancelled
ErrTypeSecretPromptTimeout
ErrTypeSecretAgentFailed
ErrTypeGeneric
)
type CustomError struct {
Type ErrorType
Message string
}
func (e *CustomError) Error() string {
return e.Message
}
func NewCustomError(errType ErrorType, message string) error {
return &CustomError{
Type: errType,
Message: message,
}
}
const (
ErrBadCredentials = "bad-credentials"
ErrNoSuchSSID = "no-such-ssid"
ErrAssocTimeout = "assoc-timeout"
ErrDhcpTimeout = "dhcp-timeout"
ErrUserCanceled = "user-canceled"
ErrWifiDisabled = "wifi-disabled"
ErrAlreadyConnected = "already-connected"
ErrConnectionFailed = "connection-failed"
)
var (
ErrUpdateCancelled = NewCustomError(ErrTypeUpdateCancelled, "update cancelled by user")
ErrNoUpdateNeeded = NewCustomError(ErrTypeNoUpdateNeeded, "no update needed")
ErrInvalidTemperature = NewCustomError(ErrTypeInvalidTemperature, "temperature must be between 1000 and 10000")
ErrInvalidGamma = NewCustomError(ErrTypeInvalidGamma, "gamma must be between 0 and 10")
ErrInvalidLocation = NewCustomError(ErrTypeInvalidLocation, "invalid latitude/longitude")
ErrInvalidManualTimes = NewCustomError(ErrTypeInvalidManualTimes, "both sunrise and sunset must be set or neither")
ErrNoWaylandDisplay = NewCustomError(ErrTypeNoWaylandDisplay, "no wayland display available")
ErrNoGammaControl = NewCustomError(ErrTypeNoGammaControl, "compositor does not support gamma control")
ErrNotInitialized = NewCustomError(ErrTypeNotInitialized, "manager not initialized")
ErrSecretPromptCancelled = NewCustomError(ErrTypeSecretPromptCancelled, "secret prompt cancelled by user")
ErrSecretPromptTimeout = NewCustomError(ErrTypeSecretPromptTimeout, "secret prompt timed out")
ErrSecretAgentFailed = NewCustomError(ErrTypeSecretAgentFailed, "secret agent operation failed")
)

View File

@@ -0,0 +1,490 @@
package greeter
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/danklinux/internal/config"
"github.com/AvengeMedia/danklinux/internal/distros"
)
// DetectDMSPath checks for DMS installation following XDG Base Directory specification
func DetectDMSPath() (string, error) {
return config.LocateDMSConfig()
}
// DetectCompositors checks which compositors are installed
func DetectCompositors() []string {
var compositors []string
if commandExists("niri") {
compositors = append(compositors, "niri")
}
if commandExists("Hyprland") {
compositors = append(compositors, "Hyprland")
}
return compositors
}
// PromptCompositorChoice asks user to choose between compositors
func PromptCompositorChoice(compositors []string) (string, error) {
fmt.Println("\nMultiple compositors detected:")
for i, comp := range compositors {
fmt.Printf("%d) %s\n", i+1, comp)
}
reader := bufio.NewReader(os.Stdin)
fmt.Print("Choose compositor for greeter (1-2): ")
response, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(response)
switch response {
case "1":
return compositors[0], nil
case "2":
if len(compositors) > 1 {
return compositors[1], nil
}
return "", fmt.Errorf("invalid choice")
default:
return "", fmt.Errorf("invalid choice")
}
}
// EnsureGreetdInstalled checks if greetd is installed and installs it if not
func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
if commandExists("greetd") {
logFunc("✓ greetd is already installed")
return nil
}
logFunc("greetd is not installed. Installing...")
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("failed to detect OS: %w", err)
}
config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists {
return fmt.Errorf("unsupported distribution for automatic greetd installation: %s", osInfo.Distribution.ID)
}
ctx := context.Background()
var installCmd *exec.Cmd
switch config.Family {
case distros.FamilyArch:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"pacman -S --needed --noconfirm greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd")
}
case distros.FamilyFedora:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"dnf install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd")
}
case distros.FamilySUSE:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"zypper install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd")
}
case distros.FamilyUbuntu:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
case distros.FamilyDebian:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
default:
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
}
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install greetd: %w", err)
}
logFunc("✓ greetd installed successfully")
return nil
}
// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory
func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
// Check if dms-greeter is already in PATH
if commandExists("dms-greeter") {
logFunc("✓ dms-greeter wrapper already installed")
} else {
// Install the wrapper script
assetsDir := filepath.Join(dmsPath, "Modules", "Greetd", "assets")
wrapperSrc := filepath.Join(assetsDir, "dms-greeter")
if _, err := os.Stat(wrapperSrc); os.IsNotExist(err) {
return fmt.Errorf("dms-greeter wrapper not found at %s", wrapperSrc)
}
wrapperDst := "/usr/local/bin/dms-greeter"
if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err)
}
logFunc(fmt.Sprintf("✓ Installed dms-greeter wrapper to %s", wrapperDst))
if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil {
return fmt.Errorf("failed to make wrapper executable: %w", err)
}
// Set SELinux context on Fedora and openSUSE
osInfo, err := distros.GetOSInfo()
if err == nil {
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) {
if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err))
} else {
logFunc("✓ Set SELinux fcontext for dms-greeter")
}
if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err))
} else {
logFunc("✓ Restored SELinux context for dms-greeter")
}
}
}
}
// Create cache directory with proper permissions
cacheDir := "/var/cache/dms-greeter"
if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := runSudoCmd(sudoPassword, "chown", "greeter:greeter", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory owner: %w", err)
}
if err := runSudoCmd(sudoPassword, "chmod", "750", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory permissions: %w", err)
}
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: greeter:greeter, permissions: 750)", cacheDir))
return nil
}
// SetupParentDirectoryACLs sets ACLs on parent directories to allow traversal
func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
if !commandExists("setfacl") {
logFunc("⚠ Warning: setfacl command not found. ACL support may not be available on this filesystem.")
logFunc(" If theme sync doesn't work, you may need to install acl package:")
logFunc(" - Fedora/RHEL: sudo dnf install acl")
logFunc(" - Debian/Ubuntu: sudo apt-get install acl")
logFunc(" - Arch: sudo pacman -S acl")
return nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
parentDirs := []struct {
path string
desc string
}{
{homeDir, "home directory"},
{filepath.Join(homeDir, ".config"), ".config directory"},
{filepath.Join(homeDir, ".local"), ".local directory"},
{filepath.Join(homeDir, ".cache"), ".cache directory"},
{filepath.Join(homeDir, ".local", "state"), ".local/state directory"},
}
logFunc("\nSetting up parent directory ACLs for greeter user access...")
for _, dir := range parentDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.desc, err))
continue
}
}
// Set ACL to allow greeter user execute (traverse) permission
if err := runSudoCmd(sudoPassword, "setfacl", "-m", "u:greeter:x", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m u:greeter:x %s", dir.path))
continue
}
logFunc(fmt.Sprintf("✓ Set ACL on %s", dir.desc))
}
return nil
}
func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return fmt.Errorf("failed to determine current user")
}
// Check if user is already in greeter group
groupsCmd := exec.Command("groups", currentUser)
groupsOutput, err := groupsCmd.Output()
if err == nil && strings.Contains(string(groupsOutput), "greeter") {
logFunc(fmt.Sprintf("✓ %s is already in greeter group", currentUser))
} else {
// Add current user to greeter group for file access permissions
if err := runSudoCmd(sudoPassword, "usermod", "-aG", "greeter", currentUser); err != nil {
return fmt.Errorf("failed to add %s to greeter group: %w", currentUser, err)
}
logFunc(fmt.Sprintf("✓ Added %s to greeter group (logout/login required for changes to take effect)", currentUser))
}
configDirs := []struct {
path string
desc string
}{
{filepath.Join(homeDir, ".config", "DankMaterialShell"), "DankMaterialShell config"},
{filepath.Join(homeDir, ".local", "state", "DankMaterialShell"), "DankMaterialShell state"},
{filepath.Join(homeDir, ".cache", "quickshell"), "quickshell cache"},
{filepath.Join(homeDir, ".config", "quickshell"), "quickshell config"},
}
for _, dir := range configDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.path, err))
continue
}
}
if err := runSudoCmd(sudoPassword, "chgrp", "-R", "greeter", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
continue
}
if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err))
continue
}
logFunc(fmt.Sprintf("✓ Set group permissions for %s", dir.desc))
}
// Set up ACLs on parent directories to allow greeter user traversal
if err := SetupParentDirectoryACLs(logFunc, sudoPassword); err != nil {
return fmt.Errorf("failed to setup parent directory ACLs: %w", err)
}
return nil
}
func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
cacheDir := "/var/cache/dms-greeter"
symlinks := []struct {
source string
target string
desc string
}{
{
source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"),
target: filepath.Join(cacheDir, "settings.json"),
desc: "core settings (theme, clock formats, etc)",
},
{
source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"),
target: filepath.Join(cacheDir, "session.json"),
desc: "state (wallpaper configuration)",
},
{
source: filepath.Join(homeDir, ".cache", "quickshell", "dankshell", "dms-colors.json"),
target: filepath.Join(cacheDir, "colors.json"),
desc: "wallpaper based theming",
},
}
for _, link := range symlinks {
sourceDir := filepath.Dir(link.source)
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
if err := os.MkdirAll(sourceDir, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create directory %s: %v", sourceDir, err))
continue
}
}
if _, err := os.Stat(link.source); os.IsNotExist(err) {
if err := os.WriteFile(link.source, []byte("{}"), 0644); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", link.source, err))
continue
}
}
runSudoCmd(sudoPassword, "rm", "-f", link.target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to create symlink for %s: %v", link.desc, err))
continue
}
logFunc(fmt.Sprintf("✓ Synced %s", link.desc))
}
return nil
}
func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
configPath := "/etc/greetd/config.toml"
if _, err := os.Stat(configPath); err == nil {
backupPath := configPath + ".backup"
if err := runSudoCmd(sudoPassword, "cp", configPath, backupPath); err != nil {
return fmt.Errorf("failed to backup config: %w", err)
}
logFunc(fmt.Sprintf("✓ Backed up existing config to %s", backupPath))
}
var configContent string
if data, err := os.ReadFile(configPath); err == nil {
configContent = string(data)
} else {
configContent = `[terminal]
vt = 1
[default_session]
user = "greeter"
`
}
lines := strings.Split(configContent, "\n")
var newLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
newLines = append(newLines, `user = "greeter"`)
} else {
newLines = append(newLines, line)
}
}
}
// Determine wrapper command path
wrapperCmd := "dms-greeter"
if !commandExists("dms-greeter") {
wrapperCmd = "/usr/local/bin/dms-greeter"
}
// Build command based on compositor and dms path
compositorLower := strings.ToLower(compositor)
command := fmt.Sprintf(`command = "%s --command %s -p %s"`, wrapperCmd, compositorLower, dmsPath)
var finalLines []string
inDefaultSession := false
commandAdded := false
for _, line := range newLines {
finalLines = append(finalLines, line)
trimmed := strings.TrimSpace(line)
if trimmed == "[default_session]" {
inDefaultSession = true
}
if inDefaultSession && !commandAdded && trimmed != "" && !strings.HasPrefix(trimmed, "[") {
if !strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "user") {
finalLines = append(finalLines, command)
commandAdded = true
}
}
}
if !commandAdded {
finalLines = append(finalLines, command)
}
newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err)
}
if err := runSudoCmd(sudoPassword, "mv", tmpFile, configPath); err != nil {
return fmt.Errorf("failed to move config to /etc/greetd: %w", err)
}
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: greeter, command: %s --command %s -p %s)", wrapperCmd, compositorLower, dmsPath))
return nil
}
func runSudoCmd(sudoPassword string, command string, args ...string) error {
var cmd *exec.Cmd
if sudoPassword != "" {
fullArgs := append([]string{command}, args...)
quotedArgs := make([]string, len(fullArgs))
for i, arg := range fullArgs {
quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
cmdStr := strings.Join(quotedArgs, " ")
cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr)
} else {
cmd = exec.Command("sudo", append([]string{command}, args...)...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}

View File

@@ -0,0 +1,331 @@
package hyprland
import (
"os"
"path/filepath"
"regexp"
"strings"
)
const (
TitleRegex = "#+!"
HideComment = "[hidden]"
CommentBindPattern = "#/#"
)
var ModSeparators = []rune{'+', ' '}
type KeyBinding struct {
Mods []string `json:"mods"`
Key string `json:"key"`
Dispatcher string `json:"dispatcher"`
Params string `json:"params"`
Comment string `json:"comment"`
}
type Section struct {
Children []Section `json:"children"`
Keybinds []KeyBinding `json:"keybinds"`
Name string `json:"name"`
}
type Parser struct {
contentLines []string
readingLine int
}
func NewParser() *Parser {
return &Parser{
contentLines: []string{},
readingLine: 0,
}
}
func (p *Parser) ReadContent(directory string) error {
expandedDir := os.ExpandEnv(directory)
expandedDir = filepath.Clean(expandedDir)
if strings.HasPrefix(expandedDir, "~") {
home, err := os.UserHomeDir()
if err != nil {
return err
}
expandedDir = filepath.Join(home, expandedDir[1:])
}
info, err := os.Stat(expandedDir)
if err != nil {
return err
}
if !info.IsDir() {
return os.ErrNotExist
}
confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf"))
if err != nil {
return err
}
if len(confFiles) == 0 {
return os.ErrNotExist
}
var combinedContent []string
for _, confFile := range confFiles {
if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() {
data, err := os.ReadFile(confFile)
if err == nil {
combinedContent = append(combinedContent, string(data))
}
}
}
if len(combinedContent) == 0 {
return os.ErrNotExist
}
fullContent := strings.Join(combinedContent, "\n")
p.contentLines = strings.Split(fullContent, "\n")
return nil
}
func autogenerateComment(dispatcher, params string) string {
switch dispatcher {
case "resizewindow":
return "Resize window"
case "movewindow":
if params == "" {
return "Move window"
}
dirMap := map[string]string{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}
if dir, ok := dirMap[params]; ok {
return "move in " + dir + " direction"
}
return "move in null direction"
case "pin":
return "pin (show on all workspaces)"
case "splitratio":
return "Window split ratio " + params
case "togglefloating":
return "Float/unfloat window"
case "resizeactive":
return "Resize window by " + params
case "killactive":
return "Close window"
case "fullscreen":
fsMap := map[string]string{
"0": "fullscreen",
"1": "maximization",
"2": "fullscreen on Hyprland's side",
}
if fs, ok := fsMap[params]; ok {
return "Toggle " + fs
}
return "Toggle null"
case "fakefullscreen":
return "Toggle fake fullscreen"
case "workspace":
switch params {
case "+1":
return "focus right"
case "-1":
return "focus left"
}
return "focus workspace " + params
case "movefocus":
dirMap := map[string]string{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}
if dir, ok := dirMap[params]; ok {
return "move focus " + dir
}
return "move focus null"
case "swapwindow":
dirMap := map[string]string{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}
if dir, ok := dirMap[params]; ok {
return "swap in " + dir + " direction"
}
return "swap in null direction"
case "movetoworkspace":
switch params {
case "+1":
return "move to right workspace (non-silent)"
case "-1":
return "move to left workspace (non-silent)"
}
return "move to workspace " + params + " (non-silent)"
case "movetoworkspacesilent":
switch params {
case "+1":
return "move to right workspace"
case "-1":
return "move to right workspace"
}
return "move to workspace " + params
case "togglespecialworkspace":
return "toggle special"
case "exec":
return params
default:
return ""
}
}
func (p *Parser) getKeybindAtLine(lineNumber int) *KeyBinding {
line := p.contentLines[lineNumber]
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return nil
}
keys := parts[1]
keyParts := strings.SplitN(keys, "#", 2)
keys = keyParts[0]
var comment string
if len(keyParts) > 1 {
comment = strings.TrimSpace(keyParts[1])
}
keyFields := strings.SplitN(keys, ",", 5)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
dispatcher := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
paramParts := keyFields[3:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
if comment != "" {
if strings.HasPrefix(comment, HideComment) {
return nil
}
} else {
comment = autogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
return &KeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
}
}
func (p *Parser) getBindsRecursive(currentContent *Section, scope int) *Section {
titleRegex := regexp.MustCompile(TitleRegex)
for p.readingLine < len(p.contentLines) {
line := p.contentLines[p.readingLine]
loc := titleRegex.FindStringIndex(line)
if loc != nil && loc[0] == 0 {
headingScope := strings.Index(line, "!")
if headingScope <= scope {
p.readingLine--
return currentContent
}
sectionName := strings.TrimSpace(line[headingScope+1:])
p.readingLine++
childSection := &Section{
Children: []Section{},
Keybinds: []KeyBinding{},
Name: sectionName,
}
result := p.getBindsRecursive(childSection, headingScope)
currentContent.Children = append(currentContent.Children, *result)
} else if strings.HasPrefix(line, CommentBindPattern) {
keybind := p.getKeybindAtLine(p.readingLine)
if keybind != nil {
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
}
} else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") {
} else {
keybind := p.getKeybindAtLine(p.readingLine)
if keybind != nil {
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
}
}
p.readingLine++
}
return currentContent
}
func (p *Parser) ParseKeys() *Section {
p.readingLine = 0
rootSection := &Section{
Children: []Section{},
Keybinds: []KeyBinding{},
Name: "",
}
return p.getBindsRecursive(rootSection, 0)
}
func ParseKeys(path string) (*Section, error) {
parser := NewParser()
if err := parser.ReadContent(path); err != nil {
return nil, err
}
return parser.ParseKeys(), nil
}

View File

@@ -0,0 +1,396 @@
package hyprland
import (
"os"
"path/filepath"
"testing"
)
func TestAutogenerateComment(t *testing.T) {
tests := []struct {
dispatcher string
params string
expected string
}{
{"resizewindow", "", "Resize window"},
{"movewindow", "", "Move window"},
{"movewindow", "l", "move in left direction"},
{"movewindow", "r", "move in right direction"},
{"movewindow", "u", "move in up direction"},
{"movewindow", "d", "move in down direction"},
{"pin", "", "pin (show on all workspaces)"},
{"splitratio", "0.5", "Window split ratio 0.5"},
{"togglefloating", "", "Float/unfloat window"},
{"resizeactive", "10 20", "Resize window by 10 20"},
{"killactive", "", "Close window"},
{"fullscreen", "0", "Toggle fullscreen"},
{"fullscreen", "1", "Toggle maximization"},
{"fullscreen", "2", "Toggle fullscreen on Hyprland's side"},
{"fakefullscreen", "", "Toggle fake fullscreen"},
{"workspace", "+1", "focus right"},
{"workspace", "-1", "focus left"},
{"workspace", "5", "focus workspace 5"},
{"movefocus", "l", "move focus left"},
{"movefocus", "r", "move focus right"},
{"movefocus", "u", "move focus up"},
{"movefocus", "d", "move focus down"},
{"swapwindow", "l", "swap in left direction"},
{"swapwindow", "r", "swap in right direction"},
{"swapwindow", "u", "swap in up direction"},
{"swapwindow", "d", "swap in down direction"},
{"movetoworkspace", "+1", "move to right workspace (non-silent)"},
{"movetoworkspace", "-1", "move to left workspace (non-silent)"},
{"movetoworkspace", "3", "move to workspace 3 (non-silent)"},
{"movetoworkspacesilent", "+1", "move to right workspace"},
{"movetoworkspacesilent", "-1", "move to right workspace"},
{"movetoworkspacesilent", "2", "move to workspace 2"},
{"togglespecialworkspace", "", "toggle special"},
{"exec", "firefox", "firefox"},
{"unknown", "", ""},
}
for _, tt := range tests {
t.Run(tt.dispatcher+"_"+tt.params, func(t *testing.T) {
result := autogenerateComment(tt.dispatcher, tt.params)
if result != tt.expected {
t.Errorf("autogenerateComment(%q, %q) = %q, want %q",
tt.dispatcher, tt.params, result, tt.expected)
}
})
}
}
func TestGetKeybindAtLine(t *testing.T) {
tests := []struct {
name string
line string
expected *KeyBinding
}{
{
name: "basic_keybind",
line: "bind = SUPER, Q, killactive",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "Q",
Dispatcher: "killactive",
Params: "",
Comment: "Close window",
},
},
{
name: "keybind_with_params",
line: "bind = SUPER, left, movefocus, l",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "left",
Dispatcher: "movefocus",
Params: "l",
Comment: "move focus left",
},
},
{
name: "keybind_with_comment",
line: "bind = SUPER, T, exec, kitty # Open terminal",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "T",
Dispatcher: "exec",
Params: "kitty",
Comment: "Open terminal",
},
},
{
name: "keybind_hidden",
line: "bind = SUPER, H, exec, secret # [hidden]",
expected: nil,
},
{
name: "keybind_multiple_mods",
line: "bind = SUPER+SHIFT, F, fullscreen, 0",
expected: &KeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "F",
Dispatcher: "fullscreen",
Params: "0",
Comment: "Toggle fullscreen",
},
},
{
name: "keybind_no_mods",
line: "bind = , Print, exec, screenshot",
expected: &KeyBinding{
Mods: []string{},
Key: "Print",
Dispatcher: "exec",
Params: "screenshot",
Comment: "screenshot",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if tt.expected == nil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Errorf("expected %+v, got nil", tt.expected)
return
}
if result.Key != tt.expected.Key {
t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key)
}
if result.Dispatcher != tt.expected.Dispatcher {
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expected.Dispatcher)
}
if result.Params != tt.expected.Params {
t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params)
}
if result.Comment != tt.expected.Comment {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment)
}
if len(result.Mods) != len(tt.expected.Mods) {
t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods))
} else {
for i := range result.Mods {
if result.Mods[i] != tt.expected.Mods[i] {
t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i])
}
}
}
})
}
}
func TestParseKeysWithSections(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "hyprland.conf")
content := `##! Window Management
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 0
###! Movement
bind = SUPER, left, movefocus, l
bind = SUPER, right, movefocus, r
##! Applications
bind = SUPER, T, exec, kitty # Terminal
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseKeys(tmpDir)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(section.Children) != 2 {
t.Errorf("Expected 2 top-level sections, got %d", len(section.Children))
}
if len(section.Children) >= 1 {
windowMgmt := section.Children[0]
if windowMgmt.Name != "Window Management" {
t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management")
}
if len(windowMgmt.Keybinds) != 2 {
t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds))
}
if len(windowMgmt.Children) != 1 {
t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children))
} else {
movement := windowMgmt.Children[0]
if movement.Name != "Movement" {
t.Errorf("Movement section name = %q, want %q", movement.Name, "Movement")
}
if len(movement.Keybinds) != 2 {
t.Errorf("Movement keybinds = %d, want 2", len(movement.Keybinds))
}
}
}
if len(section.Children) >= 2 {
apps := section.Children[1]
if apps.Name != "Applications" {
t.Errorf("Second section name = %q, want %q", apps.Name, "Applications")
}
if len(apps.Keybinds) != 1 {
t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds))
}
if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" {
t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal")
}
}
}
func TestParseKeysWithCommentBinds(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
content := `#/# = SUPER, A, exec, app1
bind = SUPER, B, exec, app2
#/# = SUPER, C, exec, app3 # Custom comment
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseKeys(tmpDir)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(section.Keybinds) != 3 {
t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds))
}
if len(section.Keybinds) > 0 && section.Keybinds[0].Key != "A" {
t.Errorf("First keybind key = %q, want %q", section.Keybinds[0].Key, "A")
}
if len(section.Keybinds) > 1 && section.Keybinds[1].Key != "B" {
t.Errorf("Second keybind key = %q, want %q", section.Keybinds[1].Key, "B")
}
if len(section.Keybinds) > 2 && section.Keybinds[2].Comment != "Custom comment" {
t.Errorf("Third keybind comment = %q, want %q", section.Keybinds[2].Comment, "Custom comment")
}
}
func TestReadContentMultipleFiles(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "a.conf")
file2 := filepath.Join(tmpDir, "b.conf")
content1 := "bind = SUPER, Q, killactive\n"
content2 := "bind = SUPER, T, exec, kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0644); err != nil {
t.Fatalf("Failed to write file1: %v", err)
}
if err := os.WriteFile(file2, []byte(content2), 0644); err != nil {
t.Fatalf("Failed to write file2: %v", err)
}
parser := NewParser()
if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
section := parser.ParseKeys()
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds from multiple files, got %d", len(section.Keybinds))
}
}
func TestReadContentErrors(t *testing.T) {
tests := []struct {
name string
path string
}{
{
name: "nonexistent_directory",
path: "/nonexistent/path/that/does/not/exist",
},
{
name: "empty_directory",
path: t.TempDir(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseKeys(tt.path)
if err == nil {
t.Error("Expected error, got nil")
}
})
}
}
func TestReadContentWithTildeExpansion(t *testing.T) {
homeDir, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0755); err != nil {
t.Skip("Cannot create test directory in home")
}
defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "test.conf")
if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
relPath, err := filepath.Rel(homeDir, tmpSubdir)
if err != nil {
t.Skip("Cannot create relative path")
}
parser := NewParser()
tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch)
if err != nil {
t.Errorf("ReadContent with tilde path failed: %v", err)
}
}
func TestKeybindWithParamsContainingCommas(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
result := parser.getKeybindAtLine(0)
if result == nil {
t.Fatal("Expected keybind, got nil")
}
expected := "notify-send 'Title' 'Message, with comma'"
if result.Params != expected {
t.Errorf("Params = %q, want %q", result.Params, expected)
}
}
func TestEmptyAndCommentLines(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
content := `
# This is a comment
bind = SUPER, Q, killactive
# Another comment
bind = SUPER, T, exec, kitty
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseKeys(tmpDir)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
}
}

View File

@@ -0,0 +1,125 @@
package keybinds
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type DiscoveryConfig struct {
SearchPaths []string
}
func DefaultDiscoveryConfig() *DiscoveryConfig {
var searchPaths []string
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
if homeDir, err := os.UserHomeDir(); err == nil {
configHome = filepath.Join(homeDir, ".config")
}
}
if configHome != "" {
searchPaths = append(searchPaths, filepath.Join(configHome, "DankMaterialShell", "cheatsheets"))
}
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs != "" {
for _, dir := range strings.Split(configDirs, ":") {
if dir != "" {
searchPaths = append(searchPaths, filepath.Join(dir, "DankMaterialShell", "cheatsheets"))
}
}
}
return &DiscoveryConfig{
SearchPaths: searchPaths,
}
}
func (d *DiscoveryConfig) FindJSONFiles() ([]string, error) {
var files []string
for _, searchPath := range d.SearchPaths {
expandedPath, err := expandPath(searchPath)
if err != nil {
continue
}
if _, err := os.Stat(expandedPath); os.IsNotExist(err) {
continue
}
entries, err := os.ReadDir(expandedPath)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
fullPath := filepath.Join(expandedPath, entry.Name())
files = append(files, fullPath)
}
}
return files, nil
}
func expandPath(path string) (string, error) {
expandedPath := os.ExpandEnv(path)
if filepath.HasPrefix(expandedPath, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
expandedPath = filepath.Join(home, expandedPath[1:])
}
return filepath.Clean(expandedPath), nil
}
type JSONProviderFactory func(filePath string) (Provider, error)
var jsonProviderFactory JSONProviderFactory
func SetJSONProviderFactory(factory JSONProviderFactory) {
jsonProviderFactory = factory
}
func AutoDiscoverProviders(registry *Registry, config *DiscoveryConfig) error {
if config == nil {
config = DefaultDiscoveryConfig()
}
if jsonProviderFactory == nil {
return nil
}
files, err := config.FindJSONFiles()
if err != nil {
return fmt.Errorf("failed to discover JSON files: %w", err)
}
for _, file := range files {
provider, err := jsonProviderFactory(file)
if err != nil {
continue
}
if err := registry.Register(provider); err != nil {
continue
}
}
return nil
}

View File

@@ -0,0 +1,285 @@
package keybinds
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultDiscoveryConfig(t *testing.T) {
oldConfigHome := os.Getenv("XDG_CONFIG_HOME")
oldConfigDirs := os.Getenv("XDG_CONFIG_DIRS")
defer func() {
os.Setenv("XDG_CONFIG_HOME", oldConfigHome)
os.Setenv("XDG_CONFIG_DIRS", oldConfigDirs)
}()
tests := []struct {
name string
configHome string
configDirs string
expectedCount int
checkFirstPath bool
firstPath string
}{
{
name: "default with no XDG vars",
configHome: "",
configDirs: "",
expectedCount: 1,
checkFirstPath: true,
},
{
name: "with XDG_CONFIG_HOME set",
configHome: "/custom/config",
configDirs: "",
expectedCount: 1,
checkFirstPath: true,
firstPath: "/custom/config/DankMaterialShell/cheatsheets",
},
{
name: "with XDG_CONFIG_DIRS set",
configHome: "/home/user/.config",
configDirs: "/etc/xdg:/opt/config",
expectedCount: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("XDG_CONFIG_HOME", tt.configHome)
os.Setenv("XDG_CONFIG_DIRS", tt.configDirs)
config := DefaultDiscoveryConfig()
if config == nil {
t.Fatal("DefaultDiscoveryConfig returned nil")
}
if len(config.SearchPaths) != tt.expectedCount {
t.Errorf("SearchPaths count = %d, want %d", len(config.SearchPaths), tt.expectedCount)
}
if tt.checkFirstPath && len(config.SearchPaths) > 0 {
if tt.firstPath != "" && config.SearchPaths[0] != tt.firstPath {
t.Errorf("SearchPaths[0] = %q, want %q", config.SearchPaths[0], tt.firstPath)
}
}
})
}
}
func TestFindJSONFiles(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "tmux.json")
file2 := filepath.Join(tmpDir, "vim.json")
txtFile := filepath.Join(tmpDir, "readme.txt")
subdir := filepath.Join(tmpDir, "subdir")
if err := os.WriteFile(file1, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file1: %v", err)
}
if err := os.WriteFile(file2, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file2: %v", err)
}
if err := os.WriteFile(txtFile, []byte("text"), 0644); err != nil {
t.Fatalf("Failed to create txt file: %v", err)
}
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir},
}
files, err := config.FindJSONFiles()
if err != nil {
t.Fatalf("FindJSONFiles failed: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 JSON files, got %d", len(files))
}
found := make(map[string]bool)
for _, f := range files {
found[filepath.Base(f)] = true
}
if !found["tmux.json"] {
t.Error("tmux.json not found")
}
if !found["vim.json"] {
t.Error("vim.json not found")
}
if found["readme.txt"] {
t.Error("readme.txt should not be included")
}
}
func TestFindJSONFilesNonexistentPath(t *testing.T) {
config := &DiscoveryConfig{
SearchPaths: []string{"/nonexistent/path"},
}
files, err := config.FindJSONFiles()
if err != nil {
t.Fatalf("FindJSONFiles failed: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 files for nonexistent path, got %d", len(files))
}
}
func TestFindJSONFilesMultiplePaths(t *testing.T) {
tmpDir1 := t.TempDir()
tmpDir2 := t.TempDir()
file1 := filepath.Join(tmpDir1, "app1.json")
file2 := filepath.Join(tmpDir2, "app2.json")
if err := os.WriteFile(file1, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file1: %v", err)
}
if err := os.WriteFile(file2, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file2: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir1, tmpDir2},
}
files, err := config.FindJSONFiles()
if err != nil {
t.Fatalf("FindJSONFiles failed: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 JSON files from multiple paths, got %d", len(files))
}
}
func TestAutoDiscoverProviders(t *testing.T) {
tmpDir := t.TempDir()
jsonContent := `{
"title": "Test App",
"provider": "testapp",
"binds": {}
}`
file := filepath.Join(tmpDir, "testapp.json")
if err := os.WriteFile(file, []byte(jsonContent), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir},
}
registry := NewRegistry()
factoryCalled := false
SetJSONProviderFactory(func(filePath string) (Provider, error) {
factoryCalled = true
return &mockProvider{name: "testapp"}, nil
})
err := AutoDiscoverProviders(registry, config)
if err != nil {
t.Fatalf("AutoDiscoverProviders failed: %v", err)
}
if !factoryCalled {
t.Error("factory was not called")
}
provider, err := registry.Get("testapp")
if err != nil {
t.Fatalf("provider not registered: %v", err)
}
if provider.Name() != "testapp" {
t.Errorf("provider name = %q, want %q", provider.Name(), "testapp")
}
}
func TestAutoDiscoverProvidersNilConfig(t *testing.T) {
registry := NewRegistry()
SetJSONProviderFactory(func(filePath string) (Provider, error) {
return &mockProvider{name: "test"}, nil
})
err := AutoDiscoverProviders(registry, nil)
if err != nil {
t.Fatalf("AutoDiscoverProviders with nil config failed: %v", err)
}
}
func TestAutoDiscoverProvidersNoFactory(t *testing.T) {
tmpDir := t.TempDir()
file := filepath.Join(tmpDir, "test.json")
if err := os.WriteFile(file, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir},
}
registry := NewRegistry()
SetJSONProviderFactory(nil)
err := AutoDiscoverProviders(registry, config)
if err != nil {
t.Fatalf("AutoDiscoverProviders should not fail without factory: %v", err)
}
providers := registry.List()
if len(providers) != 0 {
t.Errorf("expected 0 providers without factory, got %d", len(providers))
}
}
func TestExpandPathInDiscovery(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tests := []struct {
name string
input string
expected string
}{
{
name: "tilde expansion",
input: "~/test",
expected: filepath.Join(home, "test"),
},
{
name: "absolute path",
input: "/tmp/test",
expected: "/tmp/test",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandPath(tt.input)
if err != nil {
t.Fatalf("expandPath failed: %v", err)
}
if result != tt.expected {
t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,116 @@
package providers
import (
"fmt"
"strings"
"github.com/AvengeMedia/danklinux/internal/hyprland"
"github.com/AvengeMedia/danklinux/internal/keybinds"
)
type HyprlandProvider struct {
configPath string
}
func NewHyprlandProvider(configPath string) *HyprlandProvider {
if configPath == "" {
configPath = "$HOME/.config/hypr"
}
return &HyprlandProvider{
configPath: configPath,
}
}
func (h *HyprlandProvider) Name() string {
return "hyprland"
}
func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
section, err := hyprland.ParseKeys(h.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse hyprland config: %w", err)
}
categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(section, "", categorizedBinds)
return &keybinds.CheatSheet{
Title: "Hyprland Keybinds",
Provider: h.Name(),
Binds: categorizedBinds,
}, nil
}
func (h *HyprlandProvider) convertSection(section *hyprland.Section, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
currentSubcat := subcategory
if section.Name != "" {
currentSubcat = section.Name
}
for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds)
}
}
func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
switch {
case strings.Contains(dispatcher, "workspace"):
return "Workspace"
case strings.Contains(dispatcher, "monitor"):
return "Monitor"
case strings.Contains(dispatcher, "window") ||
strings.Contains(dispatcher, "focus") ||
strings.Contains(dispatcher, "move") ||
strings.Contains(dispatcher, "swap") ||
strings.Contains(dispatcher, "resize") ||
dispatcher == "killactive" ||
dispatcher == "fullscreen" ||
dispatcher == "togglefloating" ||
dispatcher == "pin" ||
dispatcher == "fakefullscreen" ||
dispatcher == "splitratio" ||
dispatcher == "resizeactive":
return "Window"
case dispatcher == "exec":
return "Execute"
case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"):
return "System"
default:
return "Other"
}
}
func (h *HyprlandProvider) convertKeybind(kb *hyprland.KeyBinding, subcategory string) keybinds.Keybind {
key := h.formatKey(kb)
desc := kb.Comment
if desc == "" {
desc = h.generateDescription(kb.Dispatcher, kb.Params)
}
return keybinds.Keybind{
Key: key,
Description: desc,
Subcategory: subcategory,
}
}
func (h *HyprlandProvider) generateDescription(dispatcher, params string) string {
if params != "" {
return dispatcher + " " + params
}
return dispatcher
}
func (h *HyprlandProvider) formatKey(kb *hyprland.KeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}

View File

@@ -0,0 +1,225 @@
package providers
import (
"os"
"path/filepath"
"testing"
)
func TestNewHyprlandProvider(t *testing.T) {
tests := []struct {
name string
configPath string
wantPath string
}{
{
name: "custom path",
configPath: "/custom/path",
wantPath: "/custom/path",
},
{
name: "empty path defaults",
configPath: "",
wantPath: "$HOME/.config/hypr",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewHyprlandProvider(tt.configPath)
if p == nil {
t.Fatal("NewHyprlandProvider returned nil")
}
if p.configPath != tt.wantPath {
t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath)
}
})
}
}
func TestHyprlandProviderName(t *testing.T) {
p := NewHyprlandProvider("")
if p.Name() != "hyprland" {
t.Errorf("Name() = %q, want %q", p.Name(), "hyprland")
}
}
func TestHyprlandProviderGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "hyprland.conf")
content := `##! Window Management
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 0
###! Movement
bind = SUPER, left, movefocus, l
bind = SUPER, right, movefocus, r
##! Applications
bind = SUPER, T, exec, kitty # Terminal
bind = SUPER, 1, workspace, 1
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
p := NewHyprlandProvider(tmpDir)
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet.Title != "Hyprland Keybinds" {
t.Errorf("Title = %q, want %q", sheet.Title, "Hyprland Keybinds")
}
if sheet.Provider != "hyprland" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "hyprland")
}
if len(sheet.Binds) == 0 {
t.Error("expected categorized bindings, got none")
}
if windowBinds, ok := sheet.Binds["Window"]; !ok || len(windowBinds) == 0 {
t.Error("expected Window category with bindings")
}
if execBinds, ok := sheet.Binds["Execute"]; !ok || len(execBinds) == 0 {
t.Error("expected Execute category with bindings")
}
if wsBinds, ok := sheet.Binds["Workspace"]; !ok || len(wsBinds) == 0 {
t.Error("expected Workspace category with bindings")
}
}
func TestHyprlandProviderGetCheatSheetError(t *testing.T) {
p := NewHyprlandProvider("/nonexistent/path")
_, err := p.GetCheatSheet()
if err == nil {
t.Error("expected error for nonexistent path, got nil")
}
}
func TestFormatKey(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
tests := []struct {
name string
content string
expected string
category string
}{
{
name: "single mod",
content: "bind = SUPER, Q, killactive",
expected: "SUPER+Q",
category: "Window",
},
{
name: "multiple mods",
content: "bind = SUPER+SHIFT, F, fullscreen, 0",
expected: "SUPER+SHIFT+F",
category: "Window",
},
{
name: "no mods",
content: "bind = , Print, exec, screenshot",
expected: "Print",
category: "Execute",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
p := NewHyprlandProvider(tmpDir)
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
categoryBinds, ok := sheet.Binds[tt.category]
if !ok || len(categoryBinds) == 0 {
t.Fatalf("expected binds in category %q", tt.category)
}
if categoryBinds[0].Key != tt.expected {
t.Errorf("Key = %q, want %q", categoryBinds[0].Key, tt.expected)
}
})
}
}
func TestDescriptionFallback(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
tests := []struct {
name string
content string
wantDesc string
}{
{
name: "autogenerated description for known dispatcher",
content: "bind = SUPER, Q, killactive",
wantDesc: "Close window",
},
{
name: "custom comment overrides autogeneration",
content: "bind = SUPER, T, exec, kitty # Open terminal",
wantDesc: "Open terminal",
},
{
name: "fallback for unknown dispatcher without params",
content: "bind = SUPER, W, unknowndispatcher",
wantDesc: "unknowndispatcher",
},
{
name: "fallback for unknown dispatcher with params",
content: "bind = SUPER, X, customdispatcher, arg1",
wantDesc: "customdispatcher arg1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
p := NewHyprlandProvider(tmpDir)
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
found := false
for _, binds := range sheet.Binds {
for _, bind := range binds {
if bind.Description == tt.wantDesc {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("expected description %q not found in any bind", tt.wantDesc)
}
})
}
}

View File

@@ -0,0 +1,130 @@
package providers
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/AvengeMedia/danklinux/internal/keybinds"
)
type JSONFileProvider struct {
filePath string
name string
}
func NewJSONFileProvider(filePath string) (*JSONFileProvider, error) {
if filePath == "" {
return nil, fmt.Errorf("file path cannot be empty")
}
expandedPath, err := expandPath(filePath)
if err != nil {
return nil, fmt.Errorf("failed to expand path: %w", err)
}
name := filepath.Base(expandedPath)
name = name[:len(name)-len(filepath.Ext(name))]
return &JSONFileProvider{
filePath: expandedPath,
name: name,
}, nil
}
func (j *JSONFileProvider) Name() string {
return j.name
}
func (j *JSONFileProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
data, err := os.ReadFile(j.filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var rawData map[string]interface{}
if err := json.Unmarshal(data, &rawData); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
title, _ := rawData["title"].(string)
provider, _ := rawData["provider"].(string)
if provider == "" {
provider = j.name
}
categorizedBinds := make(map[string][]keybinds.Keybind)
bindsRaw, ok := rawData["binds"]
if !ok {
return nil, fmt.Errorf("missing 'binds' field")
}
switch binds := bindsRaw.(type) {
case map[string]interface{}:
for category, categoryBindsRaw := range binds {
categoryBindsList, ok := categoryBindsRaw.([]interface{})
if !ok {
continue
}
var keybindsList []keybinds.Keybind
categoryBindsJSON, _ := json.Marshal(categoryBindsList)
if err := json.Unmarshal(categoryBindsJSON, &keybindsList); err != nil {
continue
}
categorizedBinds[category] = keybindsList
}
case []interface{}:
flatBindsJSON, _ := json.Marshal(binds)
var flatBinds []struct {
Key string `json:"key"`
Description string `json:"desc"`
Category string `json:"cat,omitempty"`
Subcategory string `json:"subcat,omitempty"`
}
if err := json.Unmarshal(flatBindsJSON, &flatBinds); err != nil {
return nil, fmt.Errorf("failed to parse flat binds array: %w", err)
}
for _, bind := range flatBinds {
category := bind.Category
if category == "" {
category = "Other"
}
kb := keybinds.Keybind{
Key: bind.Key,
Description: bind.Description,
Subcategory: bind.Subcategory,
}
categorizedBinds[category] = append(categorizedBinds[category], kb)
}
default:
return nil, fmt.Errorf("'binds' must be either an object (categorized) or array (flat)")
}
return &keybinds.CheatSheet{
Title: title,
Provider: provider,
Binds: categorizedBinds,
}, nil
}
func expandPath(path string) (string, error) {
expandedPath := os.ExpandEnv(path)
if filepath.HasPrefix(expandedPath, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
expandedPath = filepath.Join(home, expandedPath[1:])
}
return filepath.Clean(expandedPath), nil
}

View File

@@ -0,0 +1,279 @@
package providers
import (
"os"
"path/filepath"
"testing"
)
func TestNewJSONFileProvider(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.json")
if err := os.WriteFile(testFile, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
tests := []struct {
name string
filePath string
expectError bool
wantName string
}{
{
name: "valid file",
filePath: testFile,
expectError: false,
wantName: "test",
},
{
name: "empty path",
filePath: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, err := NewJSONFileProvider(tt.filePath)
if tt.expectError {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if p.Name() != tt.wantName {
t.Errorf("Name() = %q, want %q", p.Name(), tt.wantName)
}
})
}
}
func TestJSONFileProviderGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "tmux.json")
content := `{
"title": "Tmux Binds",
"provider": "tmux",
"binds": {
"Pane": [
{
"key": "Ctrl+Alt+J",
"desc": "Resize split downward",
"subcat": "Sizing"
},
{
"key": "Ctrl+K",
"desc": "Move Focus Up",
"subcat": "Navigation"
}
]
}
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet.Title != "Tmux Binds" {
t.Errorf("Title = %q, want %q", sheet.Title, "Tmux Binds")
}
if sheet.Provider != "tmux" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "tmux")
}
paneBinds, ok := sheet.Binds["Pane"]
if !ok {
t.Fatal("expected Pane category")
}
if len(paneBinds) != 2 {
t.Errorf("len(Pane binds) = %d, want 2", len(paneBinds))
}
if len(paneBinds) > 0 {
bind := paneBinds[0]
if bind.Key != "Ctrl+Alt+J" {
t.Errorf("Pane[0].Key = %q, want %q", bind.Key, "Ctrl+Alt+J")
}
if bind.Description != "Resize split downward" {
t.Errorf("Pane[0].Description = %q, want %q", bind.Description, "Resize split downward")
}
if bind.Subcategory != "Sizing" {
t.Errorf("Pane[0].Subcategory = %q, want %q", bind.Subcategory, "Sizing")
}
}
}
func TestJSONFileProviderGetCheatSheetNoProvider(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "custom.json")
content := `{
"title": "Custom Binds",
"binds": {}
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet.Provider != "custom" {
t.Errorf("Provider = %q, want %q (should default to filename)", sheet.Provider, "custom")
}
}
func TestJSONFileProviderFlatArrayBackwardsCompat(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "legacy.json")
content := `{
"title": "Legacy Format",
"provider": "legacy",
"binds": [
{
"key": "Ctrl+S",
"desc": "Save file",
"cat": "File",
"subcat": "Operations"
},
{
"key": "Ctrl+O",
"desc": "Open file",
"cat": "File"
},
{
"key": "Ctrl+Q",
"desc": "Quit",
"subcat": "Exit"
}
]
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
fileBinds, ok := sheet.Binds["File"]
if !ok || len(fileBinds) != 2 {
t.Errorf("expected 2 binds in File category, got %d", len(fileBinds))
}
otherBinds, ok := sheet.Binds["Other"]
if !ok || len(otherBinds) != 1 {
t.Errorf("expected 1 bind in Other category (no cat specified), got %d", len(otherBinds))
}
if len(fileBinds) > 0 {
if fileBinds[0].Subcategory != "Operations" {
t.Errorf("expected subcategory %q, got %q", "Operations", fileBinds[0].Subcategory)
}
}
}
func TestJSONFileProviderInvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(testFile, []byte("not valid json"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
_, err = p.GetCheatSheet()
if err == nil {
t.Error("expected error for invalid JSON, got nil")
}
}
func TestJSONFileProviderNonexistentFile(t *testing.T) {
p, err := NewJSONFileProvider("/nonexistent/file.json")
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
_, err = p.GetCheatSheet()
if err == nil {
t.Error("expected error for nonexistent file, got nil")
}
}
func TestExpandPath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tests := []struct {
name string
input string
expected string
}{
{
name: "tilde expansion",
input: "~/test",
expected: filepath.Join(home, "test"),
},
{
name: "no expansion needed",
input: "/absolute/path",
expected: "/absolute/path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandPath(tt.input)
if err != nil {
t.Fatalf("expandPath failed: %v", err)
}
if result != tt.expected {
t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,112 @@
package providers
import (
"fmt"
"strings"
"github.com/AvengeMedia/danklinux/internal/keybinds"
"github.com/AvengeMedia/danklinux/internal/mangowc"
)
type MangoWCProvider struct {
configPath string
}
func NewMangoWCProvider(configPath string) *MangoWCProvider {
if configPath == "" {
configPath = "$HOME/.config/mango"
}
return &MangoWCProvider{
configPath: configPath,
}
}
func (m *MangoWCProvider) Name() string {
return "mangowc"
}
func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
keybinds_list, err := mangowc.ParseKeys(m.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse mangowc config: %w", err)
}
categorizedBinds := make(map[string][]keybinds.Keybind)
for _, kb := range keybinds_list {
category := m.categorizeByCommand(kb.Command)
bind := m.convertKeybind(&kb)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
return &keybinds.CheatSheet{
Title: "MangoWC Keybinds",
Provider: m.Name(),
Binds: categorizedBinds,
}, nil
}
func (m *MangoWCProvider) categorizeByCommand(command string) string {
switch {
case strings.Contains(command, "mon"):
return "Monitor"
case command == "toggleoverview":
return "Overview"
case command == "toggle_scratchpad":
return "Scratchpad"
case strings.Contains(command, "layout") || strings.Contains(command, "proportion"):
return "Layout"
case strings.Contains(command, "gaps"):
return "Gaps"
case strings.Contains(command, "view") || strings.Contains(command, "tag"):
return "Tags"
case command == "focusstack" ||
command == "focusdir" ||
command == "exchange_client" ||
command == "killclient" ||
command == "togglefloating" ||
command == "togglefullscreen" ||
command == "togglefakefullscreen" ||
command == "togglemaximizescreen" ||
command == "toggleglobal" ||
command == "toggleoverlay" ||
command == "minimized" ||
command == "restore_minimized" ||
command == "movewin" ||
command == "resizewin":
return "Window"
case command == "spawn" || command == "spawn_shell":
return "Execute"
case command == "quit" || command == "reload_config":
return "System"
default:
return "Other"
}
}
func (m *MangoWCProvider) convertKeybind(kb *mangowc.KeyBinding) keybinds.Keybind {
key := m.formatKey(kb)
desc := kb.Comment
if desc == "" {
desc = m.generateDescription(kb.Command, kb.Params)
}
return keybinds.Keybind{
Key: key,
Description: desc,
}
}
func (m *MangoWCProvider) generateDescription(command, params string) string {
if params != "" {
return command + " " + params
}
return command
}
func (m *MangoWCProvider) formatKey(kb *mangowc.KeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}

View File

@@ -0,0 +1,313 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/danklinux/internal/mangowc"
)
func TestMangoWCProviderName(t *testing.T) {
provider := NewMangoWCProvider("")
if provider.Name() != "mangowc" {
t.Errorf("Name() = %q, want %q", provider.Name(), "mangowc")
}
}
func TestMangoWCProviderDefaultPath(t *testing.T) {
provider := NewMangoWCProvider("")
if provider.configPath != "$HOME/.config/mango" {
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango")
}
}
func TestMangoWCProviderCustomPath(t *testing.T) {
customPath := "/custom/path"
provider := NewMangoWCProvider(customPath)
if provider.configPath != customPath {
t.Errorf("configPath = %q, want %q", provider.configPath, customPath)
}
}
func TestMangoWCCategorizeByCommand(t *testing.T) {
tests := []struct {
command string
expected string
}{
{"view", "Tags"},
{"tag", "Tags"},
{"toggleview", "Tags"},
{"viewtoleft", "Tags"},
{"viewtoright", "Tags"},
{"viewtoleft_have_client", "Tags"},
{"tagtoleft", "Tags"},
{"tagtoright", "Tags"},
{"focusmon", "Monitor"},
{"tagmon", "Monitor"},
{"focusstack", "Window"},
{"focusdir", "Window"},
{"exchange_client", "Window"},
{"killclient", "Window"},
{"togglefloating", "Window"},
{"togglefullscreen", "Window"},
{"togglefakefullscreen", "Window"},
{"togglemaximizescreen", "Window"},
{"toggleglobal", "Window"},
{"toggleoverlay", "Window"},
{"minimized", "Window"},
{"restore_minimized", "Window"},
{"movewin", "Window"},
{"resizewin", "Window"},
{"toggleoverview", "Overview"},
{"toggle_scratchpad", "Scratchpad"},
{"setlayout", "Layout"},
{"switch_layout", "Layout"},
{"set_proportion", "Layout"},
{"switch_proportion_preset", "Layout"},
{"incgaps", "Gaps"},
{"togglegaps", "Gaps"},
{"spawn", "Execute"},
{"spawn_shell", "Execute"},
{"quit", "System"},
{"reload_config", "System"},
{"unknown_command", "Other"},
}
provider := NewMangoWCProvider("")
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := provider.categorizeByCommand(tt.command)
if result != tt.expected {
t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected)
}
})
}
}
func TestMangoWCFormatKey(t *testing.T) {
tests := []struct {
name string
keybind *mangowc.KeyBinding
expected string
}{
{
name: "single_mod",
keybind: &mangowc.KeyBinding{
Mods: []string{"ALT"},
Key: "q",
},
expected: "ALT+q",
},
{
name: "multiple_mods",
keybind: &mangowc.KeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "Up",
},
expected: "SUPER+SHIFT+Up",
},
{
name: "no_mods",
keybind: &mangowc.KeyBinding{
Mods: []string{},
Key: "Print",
},
expected: "Print",
},
}
provider := NewMangoWCProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.formatKey(tt.keybind)
if result != tt.expected {
t.Errorf("formatKey() = %q, want %q", result, tt.expected)
}
})
}
}
func TestMangoWCConvertKeybind(t *testing.T) {
tests := []struct {
name string
keybind *mangowc.KeyBinding
wantKey string
wantDesc string
}{
{
name: "with_comment",
keybind: &mangowc.KeyBinding{
Mods: []string{"ALT"},
Key: "t",
Command: "spawn",
Params: "kitty",
Comment: "Open terminal",
},
wantKey: "ALT+t",
wantDesc: "Open terminal",
},
{
name: "without_comment",
keybind: &mangowc.KeyBinding{
Mods: []string{"SUPER"},
Key: "r",
Command: "reload_config",
Params: "",
Comment: "",
},
wantKey: "SUPER+r",
wantDesc: "reload_config",
},
{
name: "with_params_no_comment",
keybind: &mangowc.KeyBinding{
Mods: []string{"CTRL"},
Key: "1",
Command: "view",
Params: "1,0",
Comment: "",
},
wantKey: "CTRL+1",
wantDesc: "view 1,0",
},
}
provider := NewMangoWCProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertKeybind(tt.keybind)
if result.Key != tt.wantKey {
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
}
if result.Description != tt.wantDesc {
t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc)
}
})
}
}
func TestMangoWCGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `# MangoWC Configuration
blur=0
# Key Bindings
bind=SUPER,r,reload_config
bind=Alt,t,spawn,kitty # Terminal
bind=ALT,q,killclient,
# Window management
bind=ALT,Left,focusdir,left
bind=ALT,Right,focusdir,right
bind=SUPER+SHIFT,Up,exchange_client,up
# Tags
bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0
bind=Alt,1,tag,1,0
# Layout
bind=SUPER,n,switch_layout
# Gaps
bind=ALT+SHIFT,X,incgaps,1
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet == nil {
t.Fatal("Expected non-nil CheatSheet")
}
if sheet.Title != "MangoWC Keybinds" {
t.Errorf("Title = %q, want %q", sheet.Title, "MangoWC Keybinds")
}
if sheet.Provider != "mangowc" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "mangowc")
}
categories := []string{"System", "Execute", "Window", "Tags", "Layout", "Gaps"}
for _, category := range categories {
if _, exists := sheet.Binds[category]; !exists {
t.Errorf("Expected category %q to exist", category)
}
}
if len(sheet.Binds["System"]) < 1 {
t.Error("Expected at least 1 System keybind")
}
if len(sheet.Binds["Execute"]) < 1 {
t.Error("Expected at least 1 Execute keybind")
}
if len(sheet.Binds["Window"]) < 3 {
t.Error("Expected at least 3 Window keybinds")
}
if len(sheet.Binds["Tags"]) < 3 {
t.Error("Expected at least 3 Tags keybinds")
}
}
func TestMangoWCGetCheatSheetError(t *testing.T) {
provider := NewMangoWCProvider("/nonexistent/path")
_, err := provider.GetCheatSheet()
if err == nil {
t.Error("Expected error for nonexistent path, got nil")
}
}
func TestMangoWCIntegration(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `bind=Alt,t,spawn,kitty # Open terminal
bind=ALT,q,killclient,
bind=SUPER,r,reload_config # Reload config
bind=ALT,Left,focusdir,left
bind=Ctrl,1,view,1,0
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
totalBinds := 0
for _, binds := range sheet.Binds {
totalBinds += len(binds)
}
expectedBinds := 5
if totalBinds != expectedBinds {
t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds)
}
foundTerminal := false
for _, binds := range sheet.Binds {
for _, bind := range binds {
if bind.Description == "Open terminal" && bind.Key == "Alt+t" {
foundTerminal = true
}
}
}
if !foundTerminal {
t.Error("Did not find terminal keybind with correct key and description")
}
}

View File

@@ -0,0 +1,112 @@
package providers
import (
"fmt"
"strings"
"github.com/AvengeMedia/danklinux/internal/keybinds"
"github.com/AvengeMedia/danklinux/internal/sway"
)
type SwayProvider struct {
configPath string
}
func NewSwayProvider(configPath string) *SwayProvider {
if configPath == "" {
configPath = "$HOME/.config/sway"
}
return &SwayProvider{
configPath: configPath,
}
}
func (s *SwayProvider) Name() string {
return "sway"
}
func (s *SwayProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
section, err := sway.ParseKeys(s.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse sway config: %w", err)
}
categorizedBinds := make(map[string][]keybinds.Keybind)
s.convertSection(section, "", categorizedBinds)
return &keybinds.CheatSheet{
Title: "Sway Keybinds",
Provider: s.Name(),
Binds: categorizedBinds,
}, nil
}
func (s *SwayProvider) convertSection(section *sway.Section, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
currentSubcat := subcategory
if section.Name != "" {
currentSubcat = section.Name
}
for _, kb := range section.Keybinds {
category := s.categorizeByCommand(kb.Command)
bind := s.convertKeybind(&kb, currentSubcat)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
for _, child := range section.Children {
s.convertSection(&child, currentSubcat, categorizedBinds)
}
}
func (s *SwayProvider) categorizeByCommand(command string) string {
command = strings.ToLower(command)
switch {
case strings.Contains(command, "scratchpad"):
return "Scratchpad"
case strings.Contains(command, "workspace") && strings.Contains(command, "output"):
return "Monitor"
case strings.Contains(command, "workspace"):
return "Workspace"
case strings.Contains(command, "output"):
return "Monitor"
case strings.Contains(command, "layout"):
return "Layout"
case command == "kill" ||
command == "fullscreen" || strings.Contains(command, "fullscreen") ||
command == "floating toggle" || strings.Contains(command, "floating") ||
strings.Contains(command, "focus") ||
strings.Contains(command, "move") ||
strings.Contains(command, "resize") ||
strings.Contains(command, "split"):
return "Window"
case strings.HasPrefix(command, "exec"):
return "Execute"
case command == "exit" || command == "reload":
return "System"
default:
return "Other"
}
}
func (s *SwayProvider) convertKeybind(kb *sway.KeyBinding, subcategory string) keybinds.Keybind {
key := s.formatKey(kb)
desc := kb.Comment
if desc == "" {
desc = kb.Command
}
return keybinds.Keybind{
Key: key,
Description: desc,
Subcategory: subcategory,
}
}
func (s *SwayProvider) formatKey(kb *sway.KeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}

View File

@@ -0,0 +1,290 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/danklinux/internal/sway"
)
func TestSwayProviderName(t *testing.T) {
provider := NewSwayProvider("")
if provider.Name() != "sway" {
t.Errorf("Name() = %q, want %q", provider.Name(), "sway")
}
}
func TestSwayProviderDefaultPath(t *testing.T) {
provider := NewSwayProvider("")
if provider.configPath != "$HOME/.config/sway" {
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/sway")
}
}
func TestSwayProviderCustomPath(t *testing.T) {
customPath := "/custom/path"
provider := NewSwayProvider(customPath)
if provider.configPath != customPath {
t.Errorf("configPath = %q, want %q", provider.configPath, customPath)
}
}
func TestSwayCategorizeByCommand(t *testing.T) {
tests := []struct {
command string
expected string
}{
{"workspace number 1", "Workspace"},
{"workspace prev", "Workspace"},
{"workspace next", "Workspace"},
{"move container to workspace number 1", "Workspace"},
{"focus output left", "Monitor"},
{"move workspace to output right", "Monitor"},
{"kill", "Window"},
{"fullscreen toggle", "Window"},
{"floating toggle", "Window"},
{"focus left", "Window"},
{"focus right", "Window"},
{"move left", "Window"},
{"move right", "Window"},
{"resize grow width 10px", "Window"},
{"splith", "Window"},
{"splitv", "Window"},
{"layout tabbed", "Layout"},
{"layout stacking", "Layout"},
{"move scratchpad", "Scratchpad"},
{"scratchpad show", "Scratchpad"},
{"exec kitty", "Execute"},
{"exec --no-startup-id firefox", "Execute"},
{"exit", "System"},
{"reload", "System"},
{"unknown command", "Other"},
}
provider := NewSwayProvider("")
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := provider.categorizeByCommand(tt.command)
if result != tt.expected {
t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected)
}
})
}
}
func TestSwayFormatKey(t *testing.T) {
tests := []struct {
name string
keybind *sway.KeyBinding
expected string
}{
{
name: "single_mod",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4"},
Key: "q",
},
expected: "Mod4+q",
},
{
name: "multiple_mods",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4", "Shift"},
Key: "e",
},
expected: "Mod4+Shift+e",
},
{
name: "no_mods",
keybind: &sway.KeyBinding{
Mods: []string{},
Key: "Print",
},
expected: "Print",
},
}
provider := NewSwayProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.formatKey(tt.keybind)
if result != tt.expected {
t.Errorf("formatKey() = %q, want %q", result, tt.expected)
}
})
}
}
func TestSwayConvertKeybind(t *testing.T) {
tests := []struct {
name string
keybind *sway.KeyBinding
wantKey string
wantDesc string
}{
{
name: "with_comment",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4"},
Key: "t",
Command: "exec kitty",
Comment: "Open terminal",
},
wantKey: "Mod4+t",
wantDesc: "Open terminal",
},
{
name: "without_comment",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4"},
Key: "r",
Command: "reload",
Comment: "",
},
wantKey: "Mod4+r",
wantDesc: "reload",
},
}
provider := NewSwayProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertKeybind(tt.keybind, "")
if result.Key != tt.wantKey {
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
}
if result.Description != tt.wantDesc {
t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc)
}
})
}
}
func TestSwayGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config")
content := `set $mod Mod4
set $term kitty
# System
bindsym $mod+Shift+c reload
bindsym $mod+Shift+e exit
# Applications
bindsym $mod+t exec $term
bindsym $mod+Space exec rofi
# Window Management
bindsym $mod+q kill
bindsym $mod+f fullscreen toggle
bindsym $mod+Left focus left
bindsym $mod+Right focus right
# Workspace
bindsym $mod+1 workspace number 1
bindsym $mod+2 workspace number 2
bindsym $mod+Shift+1 move container to workspace number 1
# Layout
bindsym $mod+s layout stacking
bindsym $mod+w layout tabbed
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewSwayProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet == nil {
t.Fatal("Expected non-nil CheatSheet")
}
if sheet.Title != "Sway Keybinds" {
t.Errorf("Title = %q, want %q", sheet.Title, "Sway Keybinds")
}
if sheet.Provider != "sway" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "sway")
}
categories := []string{"System", "Execute", "Window", "Workspace", "Layout"}
for _, category := range categories {
if _, exists := sheet.Binds[category]; !exists {
t.Errorf("Expected category %q to exist", category)
}
}
if len(sheet.Binds["System"]) < 2 {
t.Error("Expected at least 2 System keybinds")
}
if len(sheet.Binds["Execute"]) < 2 {
t.Error("Expected at least 2 Execute keybinds")
}
if len(sheet.Binds["Window"]) < 4 {
t.Error("Expected at least 4 Window keybinds")
}
if len(sheet.Binds["Workspace"]) < 3 {
t.Error("Expected at least 3 Workspace keybinds")
}
}
func TestSwayGetCheatSheetError(t *testing.T) {
provider := NewSwayProvider("/nonexistent/path")
_, err := provider.GetCheatSheet()
if err == nil {
t.Error("Expected error for nonexistent path, got nil")
}
}
func TestSwayIntegration(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config")
content := `set $mod Mod4
bindsym $mod+t exec kitty # Terminal
bindsym $mod+q kill
bindsym $mod+f fullscreen toggle
bindsym $mod+1 workspace number 1
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewSwayProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
totalBinds := 0
for _, binds := range sheet.Binds {
totalBinds += len(binds)
}
expectedBinds := 4
if totalBinds != expectedBinds {
t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds)
}
foundTerminal := false
for _, binds := range sheet.Binds {
for _, bind := range binds {
if bind.Description == "Terminal" && bind.Key == "Mod4+t" {
foundTerminal = true
}
}
}
if !foundTerminal {
t.Error("Did not find terminal keybind with correct key and description")
}
}

View File

@@ -0,0 +1,79 @@
package keybinds
import (
"fmt"
"sync"
)
type Registry struct {
mu sync.RWMutex
providers map[string]Provider
}
func NewRegistry() *Registry {
return &Registry{
providers: make(map[string]Provider),
}
}
func (r *Registry) Register(provider Provider) error {
if provider == nil {
return fmt.Errorf("cannot register nil provider")
}
name := provider.Name()
if name == "" {
return fmt.Errorf("provider name cannot be empty")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.providers[name]; exists {
return fmt.Errorf("provider %q already registered", name)
}
r.providers[name] = provider
return nil
}
func (r *Registry) Get(name string) (Provider, error) {
r.mu.RLock()
defer r.mu.RUnlock()
provider, exists := r.providers[name]
if !exists {
return nil, fmt.Errorf("provider %q not found", name)
}
return provider, nil
}
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.providers))
for name := range r.providers {
names = append(names, name)
}
return names
}
var defaultRegistry = NewRegistry()
func GetDefaultRegistry() *Registry {
return defaultRegistry
}
func Register(provider Provider) error {
return defaultRegistry.Register(provider)
}
func Get(name string) (Provider, error) {
return defaultRegistry.Get(name)
}
func List() []string {
return defaultRegistry.List()
}

View File

@@ -0,0 +1,183 @@
package keybinds
import (
"testing"
)
type mockProvider struct {
name string
err error
}
func (m *mockProvider) Name() string {
return m.name
}
func (m *mockProvider) GetCheatSheet() (*CheatSheet, error) {
if m.err != nil {
return nil, m.err
}
return &CheatSheet{
Title: "Test",
Provider: m.name,
Binds: make(map[string][]Keybind),
}, nil
}
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
if r == nil {
t.Fatal("NewRegistry returned nil")
}
if r.providers == nil {
t.Error("providers map is nil")
}
}
func TestRegisterProvider(t *testing.T) {
tests := []struct {
name string
provider Provider
expectError bool
errorMsg string
}{
{
name: "valid provider",
provider: &mockProvider{name: "test"},
expectError: false,
},
{
name: "nil provider",
provider: nil,
expectError: true,
errorMsg: "cannot register nil provider",
},
{
name: "empty name",
provider: &mockProvider{name: ""},
expectError: true,
errorMsg: "provider name cannot be empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRegistry()
err := r.Register(tt.provider)
if tt.expectError {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestRegisterDuplicate(t *testing.T) {
r := NewRegistry()
p := &mockProvider{name: "test"}
if err := r.Register(p); err != nil {
t.Fatalf("first registration failed: %v", err)
}
err := r.Register(p)
if err == nil {
t.Error("expected error when registering duplicate, got nil")
}
}
func TestGetProvider(t *testing.T) {
r := NewRegistry()
p := &mockProvider{name: "test"}
if err := r.Register(p); err != nil {
t.Fatalf("registration failed: %v", err)
}
retrieved, err := r.Get("test")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if retrieved.Name() != "test" {
t.Errorf("Got provider name %q, want %q", retrieved.Name(), "test")
}
}
func TestGetNonexistent(t *testing.T) {
r := NewRegistry()
_, err := r.Get("nonexistent")
if err == nil {
t.Error("expected error for nonexistent provider, got nil")
}
}
func TestListProviders(t *testing.T) {
r := NewRegistry()
p1 := &mockProvider{name: "test1"}
p2 := &mockProvider{name: "test2"}
p3 := &mockProvider{name: "test3"}
r.Register(p1)
r.Register(p2)
r.Register(p3)
list := r.List()
if len(list) != 3 {
t.Errorf("expected 3 providers, got %d", len(list))
}
found := make(map[string]bool)
for _, name := range list {
found[name] = true
}
expected := []string{"test1", "test2", "test3"}
for _, name := range expected {
if !found[name] {
t.Errorf("expected provider %q not found in list", name)
}
}
}
func TestDefaultRegistry(t *testing.T) {
p := &mockProvider{name: "default-test"}
err := Register(p)
if err != nil {
t.Fatalf("Register failed: %v", err)
}
retrieved, err := Get("default-test")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if retrieved.Name() != "default-test" {
t.Errorf("Got provider name %q, want %q", retrieved.Name(), "default-test")
}
list := List()
found := false
for _, name := range list {
if name == "default-test" {
found = true
break
}
}
if !found {
t.Error("provider not found in default registry list")
}
}

View File

@@ -0,0 +1,18 @@
package keybinds
type Keybind struct {
Key string `json:"key"`
Description string `json:"desc"`
Subcategory string `json:"subcat,omitempty"`
}
type CheatSheet struct {
Title string `json:"title"`
Provider string `json:"provider"`
Binds map[string][]Keybind `json:"binds"`
}
type Provider interface {
Name() string
GetCheatSheet() (*CheatSheet, error)
}

116
internal/log/log.go Normal file
View File

@@ -0,0 +1,116 @@
package log
import (
"os"
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log"
)
// Logger embeds the Charm Logger and adds Printf/Fatalf
type Logger struct{ *cblog.Logger }
// Printf routes goose/info-style logs through Infof.
func (l *Logger) Printf(format string, v ...interface{}) { l.Infof(format, v...) }
// Fatalf keeps gooses contract of exiting the program.
func (l *Logger) Fatalf(format string, v ...interface{}) { l.Logger.Fatalf(format, v...) }
var (
logger *Logger
initLogger sync.Once
)
func parseLogLevel(level string) cblog.Level {
switch strings.ToLower(level) {
case "debug":
return cblog.DebugLevel
case "info":
return cblog.InfoLevel
case "warn", "warning":
return cblog.WarnLevel
case "error":
return cblog.ErrorLevel
case "fatal":
return cblog.FatalLevel
default:
return cblog.InfoLevel
}
}
func GetQtLoggingRules() string {
level := os.Getenv("DMS_LOG_LEVEL")
if level == "" {
level = "info"
}
var rules []string
switch strings.ToLower(level) {
case "fatal":
rules = []string{"*.debug=false", "*.info=false", "*.warning=false", "*.critical=false"}
case "error":
rules = []string{"*.debug=false", "*.info=false", "*.warning=false"}
case "warn", "warning":
rules = []string{"*.debug=false", "*.info=false"}
case "info":
rules = []string{"*.debug=false"}
case "debug":
return ""
default:
rules = []string{"*.debug=false"}
}
return strings.Join(rules, ";")
}
// GetLogger returns a logger instance
func GetLogger() *Logger {
initLogger.Do(func() {
styles := cblog.DefaultStyles()
// Attempt to match the colors used by qml/quickshell logs
styles.Levels[cblog.FatalLevel] = lipgloss.NewStyle().
SetString(" FATAL").
Foreground(lipgloss.Color("1"))
styles.Levels[cblog.ErrorLevel] = lipgloss.NewStyle().
SetString(" ERROR").
Foreground(lipgloss.Color("9"))
styles.Levels[cblog.WarnLevel] = lipgloss.NewStyle().
SetString(" WARN").
Foreground(lipgloss.Color("3"))
styles.Levels[cblog.InfoLevel] = lipgloss.NewStyle().
SetString(" INFO").
Foreground(lipgloss.Color("2"))
styles.Levels[cblog.DebugLevel] = lipgloss.NewStyle().
SetString(" DEBUG").
Foreground(lipgloss.Color("4"))
base := cblog.New(os.Stderr)
base.SetStyles(styles)
base.SetReportTimestamp(false)
level := cblog.InfoLevel
if envLevel := os.Getenv("DMS_LOG_LEVEL"); envLevel != "" {
level = parseLogLevel(envLevel)
}
base.SetLevel(level)
base.SetPrefix(" go")
logger = &Logger{base}
})
return logger
}
// * Convenience wrappers
func Debug(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Debug(msg, keyvals...) }
func Debugf(format string, v ...interface{}) { GetLogger().Logger.Debugf(format, v...) }
func Info(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Info(msg, keyvals...) }
func Infof(format string, v ...interface{}) { GetLogger().Logger.Infof(format, v...) }
func Warn(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Warn(msg, keyvals...) }
func Warnf(format string, v ...interface{}) { GetLogger().Logger.Warnf(format, v...) }
func Error(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Error(msg, keyvals...) }
func Errorf(format string, v ...interface{}) { GetLogger().Logger.Errorf(format, v...) }
func Fatal(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Fatal(msg, keyvals...) }
func Fatalf(format string, v ...interface{}) { GetLogger().Logger.Fatalf(format, v...) }

View File

@@ -0,0 +1,305 @@
package mangowc
import (
"os"
"path/filepath"
"regexp"
"strings"
)
const (
HideComment = "[hidden]"
)
var ModSeparators = []rune{'+', ' '}
type KeyBinding struct {
Mods []string `json:"mods"`
Key string `json:"key"`
Command string `json:"command"`
Params string `json:"params"`
Comment string `json:"comment"`
}
type Parser struct {
contentLines []string
readingLine int
}
func NewParser() *Parser {
return &Parser{
contentLines: []string{},
readingLine: 0,
}
}
func (p *Parser) ReadContent(path string) error {
expandedPath := os.ExpandEnv(path)
expandedPath = filepath.Clean(expandedPath)
if strings.HasPrefix(expandedPath, "~") {
home, err := os.UserHomeDir()
if err != nil {
return err
}
expandedPath = filepath.Join(home, expandedPath[1:])
}
info, err := os.Stat(expandedPath)
if err != nil {
return err
}
var files []string
if info.IsDir() {
confFiles, err := filepath.Glob(filepath.Join(expandedPath, "*.conf"))
if err != nil {
return err
}
if len(confFiles) == 0 {
return os.ErrNotExist
}
files = confFiles
} else {
files = []string{expandedPath}
}
var combinedContent []string
for _, file := range files {
if fileInfo, err := os.Stat(file); err == nil && fileInfo.Mode().IsRegular() {
data, err := os.ReadFile(file)
if err == nil {
combinedContent = append(combinedContent, string(data))
}
}
}
if len(combinedContent) == 0 {
return os.ErrNotExist
}
fullContent := strings.Join(combinedContent, "\n")
p.contentLines = strings.Split(fullContent, "\n")
return nil
}
func autogenerateComment(command, params string) string {
switch command {
case "spawn", "spawn_shell":
return params
case "killclient":
return "Close window"
case "quit":
return "Exit MangoWC"
case "reload_config":
return "Reload configuration"
case "focusstack":
if params == "next" {
return "Focus next window"
}
if params == "prev" {
return "Focus previous window"
}
return "Focus stack " + params
case "focusdir":
dirMap := map[string]string{
"left": "left",
"right": "right",
"up": "up",
"down": "down",
}
if dir, ok := dirMap[params]; ok {
return "Focus " + dir
}
return "Focus " + params
case "exchange_client":
dirMap := map[string]string{
"left": "left",
"right": "right",
"up": "up",
"down": "down",
}
if dir, ok := dirMap[params]; ok {
return "Swap window " + dir
}
return "Swap window " + params
case "togglefloating":
return "Float/unfloat window"
case "togglefullscreen":
return "Toggle fullscreen"
case "togglefakefullscreen":
return "Toggle fake fullscreen"
case "togglemaximizescreen":
return "Toggle maximize"
case "toggleglobal":
return "Toggle global"
case "toggleoverview":
return "Toggle overview"
case "toggleoverlay":
return "Toggle overlay"
case "minimized":
return "Minimize window"
case "restore_minimized":
return "Restore minimized"
case "toggle_scratchpad":
return "Toggle scratchpad"
case "setlayout":
return "Set layout " + params
case "switch_layout":
return "Switch layout"
case "view":
parts := strings.Split(params, ",")
if len(parts) > 0 {
return "View tag " + parts[0]
}
return "View tag"
case "tag":
parts := strings.Split(params, ",")
if len(parts) > 0 {
return "Move to tag " + parts[0]
}
return "Move to tag"
case "toggleview":
parts := strings.Split(params, ",")
if len(parts) > 0 {
return "Toggle tag " + parts[0]
}
return "Toggle tag"
case "viewtoleft", "viewtoleft_have_client":
return "View left tag"
case "viewtoright", "viewtoright_have_client":
return "View right tag"
case "tagtoleft":
return "Move to left tag"
case "tagtoright":
return "Move to right tag"
case "focusmon":
return "Focus monitor " + params
case "tagmon":
return "Move to monitor " + params
case "incgaps":
if strings.HasPrefix(params, "-") {
return "Decrease gaps"
}
return "Increase gaps"
case "togglegaps":
return "Toggle gaps"
case "movewin":
return "Move window by " + params
case "resizewin":
return "Resize window by " + params
case "set_proportion":
return "Set proportion " + params
case "switch_proportion_preset":
return "Switch proportion preset"
default:
return ""
}
}
func (p *Parser) getKeybindAtLine(lineNumber int) *KeyBinding {
if lineNumber >= len(p.contentLines) {
return nil
}
line := p.contentLines[lineNumber]
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
bindType := matches[1]
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, HideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = autogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(ModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
_ = bindType
return &KeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
}
func (p *Parser) ParseKeys() []KeyBinding {
var keybinds []KeyBinding
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
line := p.contentLines[lineNumber]
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
if !strings.HasPrefix(strings.TrimSpace(line), "bind") {
continue
}
keybind := p.getKeybindAtLine(lineNumber)
if keybind != nil {
keybinds = append(keybinds, *keybind)
}
}
return keybinds
}
func ParseKeys(path string) ([]KeyBinding, error) {
parser := NewParser()
if err := parser.ReadContent(path); err != nil {
return nil, err
}
return parser.ParseKeys(), nil
}

View File

@@ -0,0 +1,499 @@
package mangowc
import (
"os"
"path/filepath"
"testing"
)
func TestAutogenerateComment(t *testing.T) {
tests := []struct {
command string
params string
expected string
}{
{"spawn", "kitty", "kitty"},
{"spawn_shell", "firefox", "firefox"},
{"killclient", "", "Close window"},
{"quit", "", "Exit MangoWC"},
{"reload_config", "", "Reload configuration"},
{"focusstack", "next", "Focus next window"},
{"focusstack", "prev", "Focus previous window"},
{"focusdir", "left", "Focus left"},
{"focusdir", "right", "Focus right"},
{"focusdir", "up", "Focus up"},
{"focusdir", "down", "Focus down"},
{"exchange_client", "left", "Swap window left"},
{"exchange_client", "right", "Swap window right"},
{"togglefloating", "", "Float/unfloat window"},
{"togglefullscreen", "", "Toggle fullscreen"},
{"togglefakefullscreen", "", "Toggle fake fullscreen"},
{"togglemaximizescreen", "", "Toggle maximize"},
{"toggleglobal", "", "Toggle global"},
{"toggleoverview", "", "Toggle overview"},
{"toggleoverlay", "", "Toggle overlay"},
{"minimized", "", "Minimize window"},
{"restore_minimized", "", "Restore minimized"},
{"toggle_scratchpad", "", "Toggle scratchpad"},
{"setlayout", "tile", "Set layout tile"},
{"switch_layout", "", "Switch layout"},
{"view", "1,0", "View tag 1"},
{"tag", "2,0", "Move to tag 2"},
{"toggleview", "3,0", "Toggle tag 3"},
{"viewtoleft", "", "View left tag"},
{"viewtoright", "", "View right tag"},
{"viewtoleft_have_client", "", "View left tag"},
{"viewtoright_have_client", "", "View right tag"},
{"tagtoleft", "", "Move to left tag"},
{"tagtoright", "", "Move to right tag"},
{"focusmon", "left", "Focus monitor left"},
{"tagmon", "right", "Move to monitor right"},
{"incgaps", "1", "Increase gaps"},
{"incgaps", "-1", "Decrease gaps"},
{"togglegaps", "", "Toggle gaps"},
{"movewin", "+0,-50", "Move window by +0,-50"},
{"resizewin", "+0,+50", "Resize window by +0,+50"},
{"set_proportion", "1.0", "Set proportion 1.0"},
{"switch_proportion_preset", "", "Switch proportion preset"},
{"unknown", "", ""},
}
for _, tt := range tests {
t.Run(tt.command+"_"+tt.params, func(t *testing.T) {
result := autogenerateComment(tt.command, tt.params)
if result != tt.expected {
t.Errorf("autogenerateComment(%q, %q) = %q, want %q",
tt.command, tt.params, result, tt.expected)
}
})
}
}
func TestGetKeybindAtLine(t *testing.T) {
tests := []struct {
name string
line string
expected *KeyBinding
}{
{
name: "basic_keybind",
line: "bind=ALT,q,killclient,",
expected: &KeyBinding{
Mods: []string{"ALT"},
Key: "q",
Command: "killclient",
Params: "",
Comment: "Close window",
},
},
{
name: "keybind_with_params",
line: "bind=ALT,Left,focusdir,left",
expected: &KeyBinding{
Mods: []string{"ALT"},
Key: "Left",
Command: "focusdir",
Params: "left",
Comment: "Focus left",
},
},
{
name: "keybind_with_comment",
line: "bind=Alt,t,spawn,kitty # Open terminal",
expected: &KeyBinding{
Mods: []string{"Alt"},
Key: "t",
Command: "spawn",
Params: "kitty",
Comment: "Open terminal",
},
},
{
name: "keybind_hidden",
line: "bind=SUPER,h,spawn,secret # [hidden]",
expected: nil,
},
{
name: "keybind_multiple_mods",
line: "bind=SUPER+SHIFT,Up,exchange_client,up",
expected: &KeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "Up",
Command: "exchange_client",
Params: "up",
Comment: "Swap window up",
},
},
{
name: "keybind_no_mods",
line: "bind=NONE,Print,spawn,screenshot",
expected: &KeyBinding{
Mods: []string{},
Key: "Print",
Command: "spawn",
Params: "screenshot",
Comment: "screenshot",
},
},
{
name: "keybind_multiple_params",
line: "bind=Ctrl,1,view,1,0",
expected: &KeyBinding{
Mods: []string{"Ctrl"},
Key: "1",
Command: "view",
Params: "1,0",
Comment: "View tag 1",
},
},
{
name: "bindl_flag",
line: "bindl=SUPER+ALT,l,spawn,dms ipc call lock lock",
expected: &KeyBinding{
Mods: []string{"SUPER", "ALT"},
Key: "l",
Command: "spawn",
Params: "dms ipc call lock lock",
Comment: "dms ipc call lock lock",
},
},
{
name: "keybind_with_spaces",
line: "bind = SUPER, r, reload_config",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "r",
Command: "reload_config",
Params: "",
Comment: "Reload configuration",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if tt.expected == nil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Errorf("expected %+v, got nil", tt.expected)
return
}
if result.Key != tt.expected.Key {
t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key)
}
if result.Command != tt.expected.Command {
t.Errorf("Command = %q, want %q", result.Command, tt.expected.Command)
}
if result.Params != tt.expected.Params {
t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params)
}
if result.Comment != tt.expected.Comment {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment)
}
if len(result.Mods) != len(tt.expected.Mods) {
t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods))
} else {
for i := range result.Mods {
if result.Mods[i] != tt.expected.Mods[i] {
t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i])
}
}
}
})
}
}
func TestParseKeys(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `# MangoWC Configuration
blur=0
border_radius=12
# Key Bindings
bind=SUPER,r,reload_config
bind=Alt,t,spawn,kitty # Terminal
bind=ALT,q,killclient,
bind=ALT,Left,focusdir,left
# Hidden binding
bind=SUPER,h,spawn,secret # [hidden]
# Multiple modifiers
bind=SUPER+SHIFT,Up,exchange_client,up
# Workspace bindings
bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
keybinds, err := ParseKeys(configFile)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
expectedCount := 7
if len(keybinds) != expectedCount {
t.Errorf("Expected %d keybinds, got %d", expectedCount, len(keybinds))
}
if len(keybinds) > 0 && keybinds[0].Command != "reload_config" {
t.Errorf("First keybind command = %q, want %q", keybinds[0].Command, "reload_config")
}
foundHidden := false
for _, kb := range keybinds {
if kb.Command == "spawn" && kb.Params == "secret" {
foundHidden = true
}
}
if foundHidden {
t.Error("Hidden keybind should not be included in results")
}
}
func TestReadContentMultipleFiles(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "a.conf")
file2 := filepath.Join(tmpDir, "b.conf")
content1 := "bind=ALT,q,killclient,\n"
content2 := "bind=Alt,t,spawn,kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0644); err != nil {
t.Fatalf("Failed to write file1: %v", err)
}
if err := os.WriteFile(file2, []byte(content2), 0644); err != nil {
t.Fatalf("Failed to write file2: %v", err)
}
parser := NewParser()
if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
keybinds := parser.ParseKeys()
if len(keybinds) != 2 {
t.Errorf("Expected 2 keybinds from multiple files, got %d", len(keybinds))
}
}
func TestReadContentSingleFile(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := "bind=ALT,q,killclient,\n"
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write config: %v", err)
}
parser := NewParser()
if err := parser.ReadContent(configFile); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
keybinds := parser.ParseKeys()
if len(keybinds) != 1 {
t.Errorf("Expected 1 keybind, got %d", len(keybinds))
}
}
func TestReadContentErrors(t *testing.T) {
tests := []struct {
name string
path string
}{
{
name: "nonexistent_directory",
path: "/nonexistent/path/that/does/not/exist",
},
{
name: "empty_directory",
path: t.TempDir(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseKeys(tt.path)
if err == nil {
t.Error("Expected error, got nil")
}
})
}
}
func TestReadContentWithTildeExpansion(t *testing.T) {
homeDir, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0755); err != nil {
t.Skip("Cannot create test directory in home")
}
defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "config.conf")
if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
relPath, err := filepath.Rel(homeDir, tmpSubdir)
if err != nil {
t.Skip("Cannot create relative path")
}
parser := NewParser()
tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch)
if err != nil {
t.Errorf("ReadContent with tilde path failed: %v", err)
}
}
func TestEmptyAndCommentLines(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `
# This is a comment
bind=ALT,q,killclient,
# Another comment
bind=Alt,t,spawn,kitty
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
keybinds, err := ParseKeys(configFile)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(keybinds) != 2 {
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(keybinds))
}
}
func TestInvalidBindLines(t *testing.T) {
tests := []struct {
name string
line string
}{
{
name: "missing_parts",
line: "bind=SUPER,q",
},
{
name: "not_bind",
line: "blur=0",
},
{
name: "empty_line",
line: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if result != nil {
t.Errorf("expected nil for invalid line, got %+v", result)
}
})
}
}
func TestRealWorldConfig(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `# Application Launchers
bind=Alt,t,spawn,kitty
bind=Alt,space,spawn,dms ipc call spotlight toggle
bind=Alt,v,spawn,dms ipc call clipboard toggle
# exit
bind=ALT+SHIFT,e,quit
bind=ALT,q,killclient,
# switch window focus
bind=SUPER,Tab,focusstack,next
bind=ALT,Left,focusdir,left
bind=ALT,Right,focusdir,right
# tag switch
bind=SUPER,Left,viewtoleft,0
bind=CTRL,Left,viewtoleft_have_client,0
bind=SUPER,Right,viewtoright,0
bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0
bind=Ctrl,3,view,3,0
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
keybinds, err := ParseKeys(configFile)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(keybinds) < 14 {
t.Errorf("Expected at least 14 keybinds, got %d", len(keybinds))
}
foundSpawn := false
foundQuit := false
foundView := false
for _, kb := range keybinds {
if kb.Command == "spawn" && kb.Params == "kitty" {
foundSpawn = true
}
if kb.Command == "quit" {
foundQuit = true
}
if kb.Command == "view" && kb.Params == "1,0" {
foundView = true
}
}
if !foundSpawn {
t.Error("Did not find spawn kitty keybind")
}
if !foundQuit {
t.Error("Did not find quit keybind")
}
if !foundView {
t.Error("Did not find view workspace 1 keybind")
}
}

View File

@@ -0,0 +1,129 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_brightness
import (
dbus "github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// MockDBusConn is an autogenerated mock type for the DBusConn type
type MockDBusConn struct {
mock.Mock
}
type MockDBusConn_Expecter struct {
mock *mock.Mock
}
func (_m *MockDBusConn) EXPECT() *MockDBusConn_Expecter {
return &MockDBusConn_Expecter{mock: &_m.Mock}
}
// Close provides a mock function with no fields
func (_m *MockDBusConn) Close() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Close")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockDBusConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
type MockDBusConn_Close_Call struct {
*mock.Call
}
// Close is a helper method to define mock.On call
func (_e *MockDBusConn_Expecter) Close() *MockDBusConn_Close_Call {
return &MockDBusConn_Close_Call{Call: _e.mock.On("Close")}
}
func (_c *MockDBusConn_Close_Call) Run(run func()) *MockDBusConn_Close_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockDBusConn_Close_Call) Return(_a0 error) *MockDBusConn_Close_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockDBusConn_Close_Call) RunAndReturn(run func() error) *MockDBusConn_Close_Call {
_c.Call.Return(run)
return _c
}
// Object provides a mock function with given fields: dest, path
func (_m *MockDBusConn) Object(dest string, path dbus.ObjectPath) dbus.BusObject {
ret := _m.Called(dest, path)
if len(ret) == 0 {
panic("no return value specified for Object")
}
var r0 dbus.BusObject
if rf, ok := ret.Get(0).(func(string, dbus.ObjectPath) dbus.BusObject); ok {
r0 = rf(dest, path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(dbus.BusObject)
}
}
return r0
}
// MockDBusConn_Object_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Object'
type MockDBusConn_Object_Call struct {
*mock.Call
}
// Object is a helper method to define mock.On call
// - dest string
// - path dbus.ObjectPath
func (_e *MockDBusConn_Expecter) Object(dest interface{}, path interface{}) *MockDBusConn_Object_Call {
return &MockDBusConn_Object_Call{Call: _e.mock.On("Object", dest, path)}
}
func (_c *MockDBusConn_Object_Call) Run(run func(dest string, path dbus.ObjectPath)) *MockDBusConn_Object_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(dbus.ObjectPath))
})
return _c
}
func (_c *MockDBusConn_Object_Call) Return(_a0 dbus.BusObject) *MockDBusConn_Object_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockDBusConn_Object_Call) RunAndReturn(run func(string, dbus.ObjectPath) dbus.BusObject) *MockDBusConn_Object_Call {
_c.Call.Return(run)
return _c
}
// NewMockDBusConn creates a new instance of MockDBusConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDBusConn(t interface {
mock.TestingT
Cleanup(func())
}) *MockDBusConn {
mock := &MockDBusConn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,405 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_cups
import (
io "io"
ipp "github.com/AvengeMedia/danklinux/pkg/ipp"
mock "github.com/stretchr/testify/mock"
)
// MockCUPSClientInterface is an autogenerated mock type for the CUPSClientInterface type
type MockCUPSClientInterface struct {
mock.Mock
}
type MockCUPSClientInterface_Expecter struct {
mock *mock.Mock
}
func (_m *MockCUPSClientInterface) EXPECT() *MockCUPSClientInterface_Expecter {
return &MockCUPSClientInterface_Expecter{mock: &_m.Mock}
}
// CancelAllJob provides a mock function with given fields: printer, purge
func (_m *MockCUPSClientInterface) CancelAllJob(printer string, purge bool) error {
ret := _m.Called(printer, purge)
if len(ret) == 0 {
panic("no return value specified for CancelAllJob")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, bool) error); ok {
r0 = rf(printer, purge)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_CancelAllJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelAllJob'
type MockCUPSClientInterface_CancelAllJob_Call struct {
*mock.Call
}
// CancelAllJob is a helper method to define mock.On call
// - printer string
// - purge bool
func (_e *MockCUPSClientInterface_Expecter) CancelAllJob(printer interface{}, purge interface{}) *MockCUPSClientInterface_CancelAllJob_Call {
return &MockCUPSClientInterface_CancelAllJob_Call{Call: _e.mock.On("CancelAllJob", printer, purge)}
}
func (_c *MockCUPSClientInterface_CancelAllJob_Call) Run(run func(printer string, purge bool)) *MockCUPSClientInterface_CancelAllJob_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(bool))
})
return _c
}
func (_c *MockCUPSClientInterface_CancelAllJob_Call) Return(_a0 error) *MockCUPSClientInterface_CancelAllJob_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_CancelAllJob_Call) RunAndReturn(run func(string, bool) error) *MockCUPSClientInterface_CancelAllJob_Call {
_c.Call.Return(run)
return _c
}
// CancelJob provides a mock function with given fields: jobID, purge
func (_m *MockCUPSClientInterface) CancelJob(jobID int, purge bool) error {
ret := _m.Called(jobID, purge)
if len(ret) == 0 {
panic("no return value specified for CancelJob")
}
var r0 error
if rf, ok := ret.Get(0).(func(int, bool) error); ok {
r0 = rf(jobID, purge)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_CancelJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelJob'
type MockCUPSClientInterface_CancelJob_Call struct {
*mock.Call
}
// CancelJob is a helper method to define mock.On call
// - jobID int
// - purge bool
func (_e *MockCUPSClientInterface_Expecter) CancelJob(jobID interface{}, purge interface{}) *MockCUPSClientInterface_CancelJob_Call {
return &MockCUPSClientInterface_CancelJob_Call{Call: _e.mock.On("CancelJob", jobID, purge)}
}
func (_c *MockCUPSClientInterface_CancelJob_Call) Run(run func(jobID int, purge bool)) *MockCUPSClientInterface_CancelJob_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(int), args[1].(bool))
})
return _c
}
func (_c *MockCUPSClientInterface_CancelJob_Call) Return(_a0 error) *MockCUPSClientInterface_CancelJob_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_CancelJob_Call) RunAndReturn(run func(int, bool) error) *MockCUPSClientInterface_CancelJob_Call {
_c.Call.Return(run)
return _c
}
// GetJobs provides a mock function with given fields: printer, class, whichJobs, myJobs, firstJobId, limit, attributes
func (_m *MockCUPSClientInterface) GetJobs(printer string, class string, whichJobs string, myJobs bool, firstJobId int, limit int, attributes []string) (map[int]ipp.Attributes, error) {
ret := _m.Called(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
if len(ret) == 0 {
panic("no return value specified for GetJobs")
}
var r0 map[int]ipp.Attributes
var r1 error
if rf, ok := ret.Get(0).(func(string, string, string, bool, int, int, []string) (map[int]ipp.Attributes, error)); ok {
return rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
}
if rf, ok := ret.Get(0).(func(string, string, string, bool, int, int, []string) map[int]ipp.Attributes); ok {
r0 = rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int]ipp.Attributes)
}
}
if rf, ok := ret.Get(1).(func(string, string, string, bool, int, int, []string) error); ok {
r1 = rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockCUPSClientInterface_GetJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobs'
type MockCUPSClientInterface_GetJobs_Call struct {
*mock.Call
}
// GetJobs is a helper method to define mock.On call
// - printer string
// - class string
// - whichJobs string
// - myJobs bool
// - firstJobId int
// - limit int
// - attributes []string
func (_e *MockCUPSClientInterface_Expecter) GetJobs(printer interface{}, class interface{}, whichJobs interface{}, myJobs interface{}, firstJobId interface{}, limit interface{}, attributes interface{}) *MockCUPSClientInterface_GetJobs_Call {
return &MockCUPSClientInterface_GetJobs_Call{Call: _e.mock.On("GetJobs", printer, class, whichJobs, myJobs, firstJobId, limit, attributes)}
}
func (_c *MockCUPSClientInterface_GetJobs_Call) Run(run func(printer string, class string, whichJobs string, myJobs bool, firstJobId int, limit int, attributes []string)) *MockCUPSClientInterface_GetJobs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(string), args[2].(string), args[3].(bool), args[4].(int), args[5].(int), args[6].([]string))
})
return _c
}
func (_c *MockCUPSClientInterface_GetJobs_Call) Return(_a0 map[int]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetJobs_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockCUPSClientInterface_GetJobs_Call) RunAndReturn(run func(string, string, string, bool, int, int, []string) (map[int]ipp.Attributes, error)) *MockCUPSClientInterface_GetJobs_Call {
_c.Call.Return(run)
return _c
}
// GetPrinters provides a mock function with given fields: attributes
func (_m *MockCUPSClientInterface) GetPrinters(attributes []string) (map[string]ipp.Attributes, error) {
ret := _m.Called(attributes)
if len(ret) == 0 {
panic("no return value specified for GetPrinters")
}
var r0 map[string]ipp.Attributes
var r1 error
if rf, ok := ret.Get(0).(func([]string) (map[string]ipp.Attributes, error)); ok {
return rf(attributes)
}
if rf, ok := ret.Get(0).(func([]string) map[string]ipp.Attributes); ok {
r0 = rf(attributes)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]ipp.Attributes)
}
}
if rf, ok := ret.Get(1).(func([]string) error); ok {
r1 = rf(attributes)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockCUPSClientInterface_GetPrinters_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPrinters'
type MockCUPSClientInterface_GetPrinters_Call struct {
*mock.Call
}
// GetPrinters is a helper method to define mock.On call
// - attributes []string
func (_e *MockCUPSClientInterface_Expecter) GetPrinters(attributes interface{}) *MockCUPSClientInterface_GetPrinters_Call {
return &MockCUPSClientInterface_GetPrinters_Call{Call: _e.mock.On("GetPrinters", attributes)}
}
func (_c *MockCUPSClientInterface_GetPrinters_Call) Run(run func(attributes []string)) *MockCUPSClientInterface_GetPrinters_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].([]string))
})
return _c
}
func (_c *MockCUPSClientInterface_GetPrinters_Call) Return(_a0 map[string]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetPrinters_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockCUPSClientInterface_GetPrinters_Call) RunAndReturn(run func([]string) (map[string]ipp.Attributes, error)) *MockCUPSClientInterface_GetPrinters_Call {
_c.Call.Return(run)
return _c
}
// PausePrinter provides a mock function with given fields: printer
func (_m *MockCUPSClientInterface) PausePrinter(printer string) error {
ret := _m.Called(printer)
if len(ret) == 0 {
panic("no return value specified for PausePrinter")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(printer)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_PausePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PausePrinter'
type MockCUPSClientInterface_PausePrinter_Call struct {
*mock.Call
}
// PausePrinter is a helper method to define mock.On call
// - printer string
func (_e *MockCUPSClientInterface_Expecter) PausePrinter(printer interface{}) *MockCUPSClientInterface_PausePrinter_Call {
return &MockCUPSClientInterface_PausePrinter_Call{Call: _e.mock.On("PausePrinter", printer)}
}
func (_c *MockCUPSClientInterface_PausePrinter_Call) Run(run func(printer string)) *MockCUPSClientInterface_PausePrinter_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockCUPSClientInterface_PausePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_PausePrinter_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_PausePrinter_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_PausePrinter_Call {
_c.Call.Return(run)
return _c
}
// ResumePrinter provides a mock function with given fields: printer
func (_m *MockCUPSClientInterface) ResumePrinter(printer string) error {
ret := _m.Called(printer)
if len(ret) == 0 {
panic("no return value specified for ResumePrinter")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(printer)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_ResumePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResumePrinter'
type MockCUPSClientInterface_ResumePrinter_Call struct {
*mock.Call
}
// ResumePrinter is a helper method to define mock.On call
// - printer string
func (_e *MockCUPSClientInterface_Expecter) ResumePrinter(printer interface{}) *MockCUPSClientInterface_ResumePrinter_Call {
return &MockCUPSClientInterface_ResumePrinter_Call{Call: _e.mock.On("ResumePrinter", printer)}
}
func (_c *MockCUPSClientInterface_ResumePrinter_Call) Run(run func(printer string)) *MockCUPSClientInterface_ResumePrinter_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockCUPSClientInterface_ResumePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_ResumePrinter_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_ResumePrinter_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_ResumePrinter_Call {
_c.Call.Return(run)
return _c
}
// SendRequest provides a mock function with given fields: url, req, additionalResponseData
func (_m *MockCUPSClientInterface) SendRequest(url string, req *ipp.Request, additionalResponseData io.Writer) (*ipp.Response, error) {
ret := _m.Called(url, req, additionalResponseData)
if len(ret) == 0 {
panic("no return value specified for SendRequest")
}
var r0 *ipp.Response
var r1 error
if rf, ok := ret.Get(0).(func(string, *ipp.Request, io.Writer) (*ipp.Response, error)); ok {
return rf(url, req, additionalResponseData)
}
if rf, ok := ret.Get(0).(func(string, *ipp.Request, io.Writer) *ipp.Response); ok {
r0 = rf(url, req, additionalResponseData)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*ipp.Response)
}
}
if rf, ok := ret.Get(1).(func(string, *ipp.Request, io.Writer) error); ok {
r1 = rf(url, req, additionalResponseData)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockCUPSClientInterface_SendRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendRequest'
type MockCUPSClientInterface_SendRequest_Call struct {
*mock.Call
}
// SendRequest is a helper method to define mock.On call
// - url string
// - req *ipp.Request
// - additionalResponseData io.Writer
func (_e *MockCUPSClientInterface_Expecter) SendRequest(url interface{}, req interface{}, additionalResponseData interface{}) *MockCUPSClientInterface_SendRequest_Call {
return &MockCUPSClientInterface_SendRequest_Call{Call: _e.mock.On("SendRequest", url, req, additionalResponseData)}
}
func (_c *MockCUPSClientInterface_SendRequest_Call) Run(run func(url string, req *ipp.Request, additionalResponseData io.Writer)) *MockCUPSClientInterface_SendRequest_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(*ipp.Request), args[2].(io.Writer))
})
return _c
}
func (_c *MockCUPSClientInterface_SendRequest_Call) Return(_a0 *ipp.Response, _a1 error) *MockCUPSClientInterface_SendRequest_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockCUPSClientInterface_SendRequest_Call) RunAndReturn(run func(string, *ipp.Request, io.Writer) (*ipp.Response, error)) *MockCUPSClientInterface_SendRequest_Call {
_c.Call.Return(run)
return _c
}
// NewMockCUPSClientInterface creates a new instance of MockCUPSClientInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockCUPSClientInterface(t interface {
mock.TestingT
Cleanup(func())
}) *MockCUPSClientInterface {
mock := &MockCUPSClientInterface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,689 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package gonetworkmanager
import (
gonetworkmanager "github.com/Wifx/gonetworkmanager/v2"
dbus "github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// MockAccessPoint is an autogenerated mock type for the AccessPoint type
type MockAccessPoint struct {
mock.Mock
}
type MockAccessPoint_Expecter struct {
mock *mock.Mock
}
func (_m *MockAccessPoint) EXPECT() *MockAccessPoint_Expecter {
return &MockAccessPoint_Expecter{mock: &_m.Mock}
}
// GetPath provides a mock function with no fields
func (_m *MockAccessPoint) GetPath() dbus.ObjectPath {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPath")
}
var r0 dbus.ObjectPath
if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(dbus.ObjectPath)
}
return r0
}
// MockAccessPoint_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
type MockAccessPoint_GetPath_Call struct {
*mock.Call
}
// GetPath is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPath() *MockAccessPoint_GetPath_Call {
return &MockAccessPoint_GetPath_Call{Call: _e.mock.On("GetPath")}
}
func (_c *MockAccessPoint_GetPath_Call) Run(run func()) *MockAccessPoint_GetPath_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockAccessPoint_GetPath_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAccessPoint_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockAccessPoint_GetPath_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFlags provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFlags'
type MockAccessPoint_GetPropertyFlags_Call struct {
*mock.Call
}
// GetPropertyFlags is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyFlags() *MockAccessPoint_GetPropertyFlags_Call {
return &MockAccessPoint_GetPropertyFlags_Call{Call: _e.mock.On("GetPropertyFlags")}
}
func (_c *MockAccessPoint_GetPropertyFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyFlags_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFrequency provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyFrequency() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFrequency")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyFrequency_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFrequency'
type MockAccessPoint_GetPropertyFrequency_Call struct {
*mock.Call
}
// GetPropertyFrequency is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyFrequency() *MockAccessPoint_GetPropertyFrequency_Call {
return &MockAccessPoint_GetPropertyFrequency_Call{Call: _e.mock.On("GetPropertyFrequency")}
}
func (_c *MockAccessPoint_GetPropertyFrequency_Call) Run(run func()) *MockAccessPoint_GetPropertyFrequency_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyFrequency_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyFrequency_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyFrequency_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyFrequency_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyHWAddress provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyHWAddress() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyHWAddress")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyHWAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHWAddress'
type MockAccessPoint_GetPropertyHWAddress_Call struct {
*mock.Call
}
// GetPropertyHWAddress is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyHWAddress() *MockAccessPoint_GetPropertyHWAddress_Call {
return &MockAccessPoint_GetPropertyHWAddress_Call{Call: _e.mock.On("GetPropertyHWAddress")}
}
func (_c *MockAccessPoint_GetPropertyHWAddress_Call) Run(run func()) *MockAccessPoint_GetPropertyHWAddress_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyHWAddress_Call) Return(_a0 string, _a1 error) *MockAccessPoint_GetPropertyHWAddress_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyHWAddress_Call) RunAndReturn(run func() (string, error)) *MockAccessPoint_GetPropertyHWAddress_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyLastSeen provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyLastSeen() (int32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyLastSeen")
}
var r0 int32
var r1 error
if rf, ok := ret.Get(0).(func() (int32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() int32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(int32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyLastSeen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyLastSeen'
type MockAccessPoint_GetPropertyLastSeen_Call struct {
*mock.Call
}
// GetPropertyLastSeen is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyLastSeen() *MockAccessPoint_GetPropertyLastSeen_Call {
return &MockAccessPoint_GetPropertyLastSeen_Call{Call: _e.mock.On("GetPropertyLastSeen")}
}
func (_c *MockAccessPoint_GetPropertyLastSeen_Call) Run(run func()) *MockAccessPoint_GetPropertyLastSeen_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyLastSeen_Call) Return(_a0 int32, _a1 error) *MockAccessPoint_GetPropertyLastSeen_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyLastSeen_Call) RunAndReturn(run func() (int32, error)) *MockAccessPoint_GetPropertyLastSeen_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyMaxBitrate provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyMaxBitrate() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyMaxBitrate")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyMaxBitrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMaxBitrate'
type MockAccessPoint_GetPropertyMaxBitrate_Call struct {
*mock.Call
}
// GetPropertyMaxBitrate is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyMaxBitrate() *MockAccessPoint_GetPropertyMaxBitrate_Call {
return &MockAccessPoint_GetPropertyMaxBitrate_Call{Call: _e.mock.On("GetPropertyMaxBitrate")}
}
func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) Run(run func()) *MockAccessPoint_GetPropertyMaxBitrate_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyMaxBitrate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyMaxBitrate_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyMode provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyMode() (gonetworkmanager.Nm80211Mode, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyMode")
}
var r0 gonetworkmanager.Nm80211Mode
var r1 error
if rf, ok := ret.Get(0).(func() (gonetworkmanager.Nm80211Mode, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() gonetworkmanager.Nm80211Mode); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(gonetworkmanager.Nm80211Mode)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMode'
type MockAccessPoint_GetPropertyMode_Call struct {
*mock.Call
}
// GetPropertyMode is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyMode() *MockAccessPoint_GetPropertyMode_Call {
return &MockAccessPoint_GetPropertyMode_Call{Call: _e.mock.On("GetPropertyMode")}
}
func (_c *MockAccessPoint_GetPropertyMode_Call) Run(run func()) *MockAccessPoint_GetPropertyMode_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyMode_Call) Return(_a0 gonetworkmanager.Nm80211Mode, _a1 error) *MockAccessPoint_GetPropertyMode_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyMode_Call) RunAndReturn(run func() (gonetworkmanager.Nm80211Mode, error)) *MockAccessPoint_GetPropertyMode_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyRSNFlags provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyRSNFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyRSNFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyRSNFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRSNFlags'
type MockAccessPoint_GetPropertyRSNFlags_Call struct {
*mock.Call
}
// GetPropertyRSNFlags is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyRSNFlags() *MockAccessPoint_GetPropertyRSNFlags_Call {
return &MockAccessPoint_GetPropertyRSNFlags_Call{Call: _e.mock.On("GetPropertyRSNFlags")}
}
func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyRSNFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyRSNFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyRSNFlags_Call {
_c.Call.Return(run)
return _c
}
// GetPropertySSID provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertySSID() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertySSID")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertySSID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySSID'
type MockAccessPoint_GetPropertySSID_Call struct {
*mock.Call
}
// GetPropertySSID is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertySSID() *MockAccessPoint_GetPropertySSID_Call {
return &MockAccessPoint_GetPropertySSID_Call{Call: _e.mock.On("GetPropertySSID")}
}
func (_c *MockAccessPoint_GetPropertySSID_Call) Run(run func()) *MockAccessPoint_GetPropertySSID_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertySSID_Call) Return(_a0 string, _a1 error) *MockAccessPoint_GetPropertySSID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertySSID_Call) RunAndReturn(run func() (string, error)) *MockAccessPoint_GetPropertySSID_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyStrength provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyStrength() (uint8, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyStrength")
}
var r0 uint8
var r1 error
if rf, ok := ret.Get(0).(func() (uint8, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint8); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint8)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyStrength_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyStrength'
type MockAccessPoint_GetPropertyStrength_Call struct {
*mock.Call
}
// GetPropertyStrength is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyStrength() *MockAccessPoint_GetPropertyStrength_Call {
return &MockAccessPoint_GetPropertyStrength_Call{Call: _e.mock.On("GetPropertyStrength")}
}
func (_c *MockAccessPoint_GetPropertyStrength_Call) Run(run func()) *MockAccessPoint_GetPropertyStrength_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyStrength_Call) Return(_a0 uint8, _a1 error) *MockAccessPoint_GetPropertyStrength_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyStrength_Call) RunAndReturn(run func() (uint8, error)) *MockAccessPoint_GetPropertyStrength_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyWPAFlags provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyWPAFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyWPAFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyWPAFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWPAFlags'
type MockAccessPoint_GetPropertyWPAFlags_Call struct {
*mock.Call
}
// GetPropertyWPAFlags is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyWPAFlags() *MockAccessPoint_GetPropertyWPAFlags_Call {
return &MockAccessPoint_GetPropertyWPAFlags_Call{Call: _e.mock.On("GetPropertyWPAFlags")}
}
func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyWPAFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyWPAFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyWPAFlags_Call {
_c.Call.Return(run)
return _c
}
// MarshalJSON provides a mock function with no fields
func (_m *MockAccessPoint) MarshalJSON() ([]byte, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for MarshalJSON")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func() ([]byte, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []byte); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
type MockAccessPoint_MarshalJSON_Call struct {
*mock.Call
}
// MarshalJSON is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) MarshalJSON() *MockAccessPoint_MarshalJSON_Call {
return &MockAccessPoint_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
}
func (_c *MockAccessPoint_MarshalJSON_Call) Run(run func()) *MockAccessPoint_MarshalJSON_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockAccessPoint_MarshalJSON_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockAccessPoint_MarshalJSON_Call {
_c.Call.Return(run)
return _c
}
// NewMockAccessPoint creates a new instance of MockAccessPoint. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAccessPoint(t interface {
mock.TestingT
Cleanup(func())
}) *MockAccessPoint {
mock := &MockAccessPoint{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,646 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package gonetworkmanager
import (
gonetworkmanager "github.com/Wifx/gonetworkmanager/v2"
dbus "github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// MockConnection is an autogenerated mock type for the Connection type
type MockConnection struct {
mock.Mock
}
type MockConnection_Expecter struct {
mock *mock.Mock
}
func (_m *MockConnection) EXPECT() *MockConnection_Expecter {
return &MockConnection_Expecter{mock: &_m.Mock}
}
// ClearSecrets provides a mock function with no fields
func (_m *MockConnection) ClearSecrets() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for ClearSecrets")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_ClearSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearSecrets'
type MockConnection_ClearSecrets_Call struct {
*mock.Call
}
// ClearSecrets is a helper method to define mock.On call
func (_e *MockConnection_Expecter) ClearSecrets() *MockConnection_ClearSecrets_Call {
return &MockConnection_ClearSecrets_Call{Call: _e.mock.On("ClearSecrets")}
}
func (_c *MockConnection_ClearSecrets_Call) Run(run func()) *MockConnection_ClearSecrets_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_ClearSecrets_Call) Return(_a0 error) *MockConnection_ClearSecrets_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_ClearSecrets_Call) RunAndReturn(run func() error) *MockConnection_ClearSecrets_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with no fields
func (_m *MockConnection) Delete() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockConnection_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
func (_e *MockConnection_Expecter) Delete() *MockConnection_Delete_Call {
return &MockConnection_Delete_Call{Call: _e.mock.On("Delete")}
}
func (_c *MockConnection_Delete_Call) Run(run func()) *MockConnection_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_Delete_Call) Return(_a0 error) *MockConnection_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Delete_Call) RunAndReturn(run func() error) *MockConnection_Delete_Call {
_c.Call.Return(run)
return _c
}
// GetPath provides a mock function with no fields
func (_m *MockConnection) GetPath() dbus.ObjectPath {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPath")
}
var r0 dbus.ObjectPath
if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(dbus.ObjectPath)
}
return r0
}
// MockConnection_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
type MockConnection_GetPath_Call struct {
*mock.Call
}
// GetPath is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPath() *MockConnection_GetPath_Call {
return &MockConnection_GetPath_Call{Call: _e.mock.On("GetPath")}
}
func (_c *MockConnection_GetPath_Call) Run(run func()) *MockConnection_GetPath_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockConnection_GetPath_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockConnection_GetPath_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFilename provides a mock function with no fields
func (_m *MockConnection) GetPropertyFilename() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFilename")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetPropertyFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFilename'
type MockConnection_GetPropertyFilename_Call struct {
*mock.Call
}
// GetPropertyFilename is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPropertyFilename() *MockConnection_GetPropertyFilename_Call {
return &MockConnection_GetPropertyFilename_Call{Call: _e.mock.On("GetPropertyFilename")}
}
func (_c *MockConnection_GetPropertyFilename_Call) Run(run func()) *MockConnection_GetPropertyFilename_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPropertyFilename_Call) Return(_a0 string, _a1 error) *MockConnection_GetPropertyFilename_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetPropertyFilename_Call) RunAndReturn(run func() (string, error)) *MockConnection_GetPropertyFilename_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFlags provides a mock function with no fields
func (_m *MockConnection) GetPropertyFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetPropertyFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFlags'
type MockConnection_GetPropertyFlags_Call struct {
*mock.Call
}
// GetPropertyFlags is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPropertyFlags() *MockConnection_GetPropertyFlags_Call {
return &MockConnection_GetPropertyFlags_Call{Call: _e.mock.On("GetPropertyFlags")}
}
func (_c *MockConnection_GetPropertyFlags_Call) Run(run func()) *MockConnection_GetPropertyFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPropertyFlags_Call) Return(_a0 uint32, _a1 error) *MockConnection_GetPropertyFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetPropertyFlags_Call) RunAndReturn(run func() (uint32, error)) *MockConnection_GetPropertyFlags_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyUnsaved provides a mock function with no fields
func (_m *MockConnection) GetPropertyUnsaved() (bool, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyUnsaved")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func() (bool, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetPropertyUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUnsaved'
type MockConnection_GetPropertyUnsaved_Call struct {
*mock.Call
}
// GetPropertyUnsaved is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPropertyUnsaved() *MockConnection_GetPropertyUnsaved_Call {
return &MockConnection_GetPropertyUnsaved_Call{Call: _e.mock.On("GetPropertyUnsaved")}
}
func (_c *MockConnection_GetPropertyUnsaved_Call) Run(run func()) *MockConnection_GetPropertyUnsaved_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPropertyUnsaved_Call) Return(_a0 bool, _a1 error) *MockConnection_GetPropertyUnsaved_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetPropertyUnsaved_Call) RunAndReturn(run func() (bool, error)) *MockConnection_GetPropertyUnsaved_Call {
_c.Call.Return(run)
return _c
}
// GetSecrets provides a mock function with given fields: settingName
func (_m *MockConnection) GetSecrets(settingName string) (gonetworkmanager.ConnectionSettings, error) {
ret := _m.Called(settingName)
if len(ret) == 0 {
panic("no return value specified for GetSecrets")
}
var r0 gonetworkmanager.ConnectionSettings
var r1 error
if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.ConnectionSettings, error)); ok {
return rf(settingName)
}
if rf, ok := ret.Get(0).(func(string) gonetworkmanager.ConnectionSettings); ok {
r0 = rf(settingName)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(gonetworkmanager.ConnectionSettings)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(settingName)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSecrets'
type MockConnection_GetSecrets_Call struct {
*mock.Call
}
// GetSecrets is a helper method to define mock.On call
// - settingName string
func (_e *MockConnection_Expecter) GetSecrets(settingName interface{}) *MockConnection_GetSecrets_Call {
return &MockConnection_GetSecrets_Call{Call: _e.mock.On("GetSecrets", settingName)}
}
func (_c *MockConnection_GetSecrets_Call) Run(run func(settingName string)) *MockConnection_GetSecrets_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockConnection_GetSecrets_Call) Return(_a0 gonetworkmanager.ConnectionSettings, _a1 error) *MockConnection_GetSecrets_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetSecrets_Call) RunAndReturn(run func(string) (gonetworkmanager.ConnectionSettings, error)) *MockConnection_GetSecrets_Call {
_c.Call.Return(run)
return _c
}
// GetSettings provides a mock function with no fields
func (_m *MockConnection) GetSettings() (gonetworkmanager.ConnectionSettings, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSettings")
}
var r0 gonetworkmanager.ConnectionSettings
var r1 error
if rf, ok := ret.Get(0).(func() (gonetworkmanager.ConnectionSettings, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() gonetworkmanager.ConnectionSettings); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(gonetworkmanager.ConnectionSettings)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSettings'
type MockConnection_GetSettings_Call struct {
*mock.Call
}
// GetSettings is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetSettings() *MockConnection_GetSettings_Call {
return &MockConnection_GetSettings_Call{Call: _e.mock.On("GetSettings")}
}
func (_c *MockConnection_GetSettings_Call) Run(run func()) *MockConnection_GetSettings_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetSettings_Call) Return(_a0 gonetworkmanager.ConnectionSettings, _a1 error) *MockConnection_GetSettings_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetSettings_Call) RunAndReturn(run func() (gonetworkmanager.ConnectionSettings, error)) *MockConnection_GetSettings_Call {
_c.Call.Return(run)
return _c
}
// MarshalJSON provides a mock function with no fields
func (_m *MockConnection) MarshalJSON() ([]byte, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for MarshalJSON")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func() ([]byte, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []byte); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
type MockConnection_MarshalJSON_Call struct {
*mock.Call
}
// MarshalJSON is a helper method to define mock.On call
func (_e *MockConnection_Expecter) MarshalJSON() *MockConnection_MarshalJSON_Call {
return &MockConnection_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
}
func (_c *MockConnection_MarshalJSON_Call) Run(run func()) *MockConnection_MarshalJSON_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockConnection_MarshalJSON_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockConnection_MarshalJSON_Call {
_c.Call.Return(run)
return _c
}
// Save provides a mock function with no fields
func (_m *MockConnection) Save() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Save")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
type MockConnection_Save_Call struct {
*mock.Call
}
// Save is a helper method to define mock.On call
func (_e *MockConnection_Expecter) Save() *MockConnection_Save_Call {
return &MockConnection_Save_Call{Call: _e.mock.On("Save")}
}
func (_c *MockConnection_Save_Call) Run(run func()) *MockConnection_Save_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_Save_Call) Return(_a0 error) *MockConnection_Save_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Save_Call) RunAndReturn(run func() error) *MockConnection_Save_Call {
_c.Call.Return(run)
return _c
}
// Update provides a mock function with given fields: settings
func (_m *MockConnection) Update(settings gonetworkmanager.ConnectionSettings) error {
ret := _m.Called(settings)
if len(ret) == 0 {
panic("no return value specified for Update")
}
var r0 error
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) error); ok {
r0 = rf(settings)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
type MockConnection_Update_Call struct {
*mock.Call
}
// Update is a helper method to define mock.On call
// - settings gonetworkmanager.ConnectionSettings
func (_e *MockConnection_Expecter) Update(settings interface{}) *MockConnection_Update_Call {
return &MockConnection_Update_Call{Call: _e.mock.On("Update", settings)}
}
func (_c *MockConnection_Update_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockConnection_Update_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(gonetworkmanager.ConnectionSettings))
})
return _c
}
func (_c *MockConnection_Update_Call) Return(_a0 error) *MockConnection_Update_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Update_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) error) *MockConnection_Update_Call {
_c.Call.Return(run)
return _c
}
// UpdateUnsaved provides a mock function with given fields: settings
func (_m *MockConnection) UpdateUnsaved(settings gonetworkmanager.ConnectionSettings) error {
ret := _m.Called(settings)
if len(ret) == 0 {
panic("no return value specified for UpdateUnsaved")
}
var r0 error
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) error); ok {
r0 = rf(settings)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_UpdateUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUnsaved'
type MockConnection_UpdateUnsaved_Call struct {
*mock.Call
}
// UpdateUnsaved is a helper method to define mock.On call
// - settings gonetworkmanager.ConnectionSettings
func (_e *MockConnection_Expecter) UpdateUnsaved(settings interface{}) *MockConnection_UpdateUnsaved_Call {
return &MockConnection_UpdateUnsaved_Call{Call: _e.mock.On("UpdateUnsaved", settings)}
}
func (_c *MockConnection_UpdateUnsaved_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockConnection_UpdateUnsaved_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(gonetworkmanager.ConnectionSettings))
})
return _c
}
func (_c *MockConnection_UpdateUnsaved_Call) Return(_a0 error) *MockConnection_UpdateUnsaved_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_UpdateUnsaved_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) error) *MockConnection_UpdateUnsaved_Call {
_c.Call.Return(run)
return _c
}
// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockConnection(t interface {
mock.TestingT
Cleanup(func())
}) *MockConnection {
mock := &MockConnection{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,772 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package gonetworkmanager
import (
gonetworkmanager "github.com/Wifx/gonetworkmanager/v2"
mock "github.com/stretchr/testify/mock"
)
// MockIP4Config is an autogenerated mock type for the IP4Config type
type MockIP4Config struct {
mock.Mock
}
type MockIP4Config_Expecter struct {
mock *mock.Mock
}
func (_m *MockIP4Config) EXPECT() *MockIP4Config_Expecter {
return &MockIP4Config_Expecter{mock: &_m.Mock}
}
// GetPropertyAddressData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyAddressData() ([]gonetworkmanager.IP4AddressData, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyAddressData")
}
var r0 []gonetworkmanager.IP4AddressData
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4AddressData, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4AddressData); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4AddressData)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyAddressData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAddressData'
type MockIP4Config_GetPropertyAddressData_Call struct {
*mock.Call
}
// GetPropertyAddressData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyAddressData() *MockIP4Config_GetPropertyAddressData_Call {
return &MockIP4Config_GetPropertyAddressData_Call{Call: _e.mock.On("GetPropertyAddressData")}
}
func (_c *MockIP4Config_GetPropertyAddressData_Call) Run(run func()) *MockIP4Config_GetPropertyAddressData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyAddressData_Call) Return(_a0 []gonetworkmanager.IP4AddressData, _a1 error) *MockIP4Config_GetPropertyAddressData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyAddressData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4AddressData, error)) *MockIP4Config_GetPropertyAddressData_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyAddresses provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyAddresses() ([]gonetworkmanager.IP4Address, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyAddresses")
}
var r0 []gonetworkmanager.IP4Address
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4Address, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4Address); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4Address)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyAddresses_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAddresses'
type MockIP4Config_GetPropertyAddresses_Call struct {
*mock.Call
}
// GetPropertyAddresses is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyAddresses() *MockIP4Config_GetPropertyAddresses_Call {
return &MockIP4Config_GetPropertyAddresses_Call{Call: _e.mock.On("GetPropertyAddresses")}
}
func (_c *MockIP4Config_GetPropertyAddresses_Call) Run(run func()) *MockIP4Config_GetPropertyAddresses_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyAddresses_Call) Return(_a0 []gonetworkmanager.IP4Address, _a1 error) *MockIP4Config_GetPropertyAddresses_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyAddresses_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4Address, error)) *MockIP4Config_GetPropertyAddresses_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyDnsOptions provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyDnsOptions() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyDnsOptions")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyDnsOptions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDnsOptions'
type MockIP4Config_GetPropertyDnsOptions_Call struct {
*mock.Call
}
// GetPropertyDnsOptions is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyDnsOptions() *MockIP4Config_GetPropertyDnsOptions_Call {
return &MockIP4Config_GetPropertyDnsOptions_Call{Call: _e.mock.On("GetPropertyDnsOptions")}
}
func (_c *MockIP4Config_GetPropertyDnsOptions_Call) Run(run func()) *MockIP4Config_GetPropertyDnsOptions_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyDnsOptions_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyDnsOptions_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyDnsOptions_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyDnsOptions_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyDnsPriority provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyDnsPriority() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyDnsPriority")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyDnsPriority_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDnsPriority'
type MockIP4Config_GetPropertyDnsPriority_Call struct {
*mock.Call
}
// GetPropertyDnsPriority is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyDnsPriority() *MockIP4Config_GetPropertyDnsPriority_Call {
return &MockIP4Config_GetPropertyDnsPriority_Call{Call: _e.mock.On("GetPropertyDnsPriority")}
}
func (_c *MockIP4Config_GetPropertyDnsPriority_Call) Run(run func()) *MockIP4Config_GetPropertyDnsPriority_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyDnsPriority_Call) Return(_a0 uint32, _a1 error) *MockIP4Config_GetPropertyDnsPriority_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyDnsPriority_Call) RunAndReturn(run func() (uint32, error)) *MockIP4Config_GetPropertyDnsPriority_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyDomains provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyDomains() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyDomains")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDomains'
type MockIP4Config_GetPropertyDomains_Call struct {
*mock.Call
}
// GetPropertyDomains is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyDomains() *MockIP4Config_GetPropertyDomains_Call {
return &MockIP4Config_GetPropertyDomains_Call{Call: _e.mock.On("GetPropertyDomains")}
}
func (_c *MockIP4Config_GetPropertyDomains_Call) Run(run func()) *MockIP4Config_GetPropertyDomains_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyDomains_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyDomains_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyDomains_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyDomains_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyGateway provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyGateway() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyGateway")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyGateway_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyGateway'
type MockIP4Config_GetPropertyGateway_Call struct {
*mock.Call
}
// GetPropertyGateway is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyGateway() *MockIP4Config_GetPropertyGateway_Call {
return &MockIP4Config_GetPropertyGateway_Call{Call: _e.mock.On("GetPropertyGateway")}
}
func (_c *MockIP4Config_GetPropertyGateway_Call) Run(run func()) *MockIP4Config_GetPropertyGateway_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyGateway_Call) Return(_a0 string, _a1 error) *MockIP4Config_GetPropertyGateway_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyGateway_Call) RunAndReturn(run func() (string, error)) *MockIP4Config_GetPropertyGateway_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyNameserverData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyNameserverData() ([]gonetworkmanager.IP4NameserverData, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyNameserverData")
}
var r0 []gonetworkmanager.IP4NameserverData
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4NameserverData, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4NameserverData); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4NameserverData)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyNameserverData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNameserverData'
type MockIP4Config_GetPropertyNameserverData_Call struct {
*mock.Call
}
// GetPropertyNameserverData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyNameserverData() *MockIP4Config_GetPropertyNameserverData_Call {
return &MockIP4Config_GetPropertyNameserverData_Call{Call: _e.mock.On("GetPropertyNameserverData")}
}
func (_c *MockIP4Config_GetPropertyNameserverData_Call) Run(run func()) *MockIP4Config_GetPropertyNameserverData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyNameserverData_Call) Return(_a0 []gonetworkmanager.IP4NameserverData, _a1 error) *MockIP4Config_GetPropertyNameserverData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyNameserverData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4NameserverData, error)) *MockIP4Config_GetPropertyNameserverData_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyNameservers provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyNameservers() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyNameservers")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyNameservers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNameservers'
type MockIP4Config_GetPropertyNameservers_Call struct {
*mock.Call
}
// GetPropertyNameservers is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyNameservers() *MockIP4Config_GetPropertyNameservers_Call {
return &MockIP4Config_GetPropertyNameservers_Call{Call: _e.mock.On("GetPropertyNameservers")}
}
func (_c *MockIP4Config_GetPropertyNameservers_Call) Run(run func()) *MockIP4Config_GetPropertyNameservers_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyNameservers_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyNameservers_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyNameservers_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyNameservers_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyRouteData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyRouteData() ([]gonetworkmanager.IP4RouteData, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyRouteData")
}
var r0 []gonetworkmanager.IP4RouteData
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4RouteData, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4RouteData); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4RouteData)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyRouteData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRouteData'
type MockIP4Config_GetPropertyRouteData_Call struct {
*mock.Call
}
// GetPropertyRouteData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyRouteData() *MockIP4Config_GetPropertyRouteData_Call {
return &MockIP4Config_GetPropertyRouteData_Call{Call: _e.mock.On("GetPropertyRouteData")}
}
func (_c *MockIP4Config_GetPropertyRouteData_Call) Run(run func()) *MockIP4Config_GetPropertyRouteData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyRouteData_Call) Return(_a0 []gonetworkmanager.IP4RouteData, _a1 error) *MockIP4Config_GetPropertyRouteData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyRouteData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4RouteData, error)) *MockIP4Config_GetPropertyRouteData_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyRoutes provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyRoutes() ([]gonetworkmanager.IP4Route, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyRoutes")
}
var r0 []gonetworkmanager.IP4Route
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4Route, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4Route); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4Route)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRoutes'
type MockIP4Config_GetPropertyRoutes_Call struct {
*mock.Call
}
// GetPropertyRoutes is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyRoutes() *MockIP4Config_GetPropertyRoutes_Call {
return &MockIP4Config_GetPropertyRoutes_Call{Call: _e.mock.On("GetPropertyRoutes")}
}
func (_c *MockIP4Config_GetPropertyRoutes_Call) Run(run func()) *MockIP4Config_GetPropertyRoutes_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyRoutes_Call) Return(_a0 []gonetworkmanager.IP4Route, _a1 error) *MockIP4Config_GetPropertyRoutes_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyRoutes_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4Route, error)) *MockIP4Config_GetPropertyRoutes_Call {
_c.Call.Return(run)
return _c
}
// GetPropertySearches provides a mock function with no fields
func (_m *MockIP4Config) GetPropertySearches() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertySearches")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertySearches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySearches'
type MockIP4Config_GetPropertySearches_Call struct {
*mock.Call
}
// GetPropertySearches is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertySearches() *MockIP4Config_GetPropertySearches_Call {
return &MockIP4Config_GetPropertySearches_Call{Call: _e.mock.On("GetPropertySearches")}
}
func (_c *MockIP4Config_GetPropertySearches_Call) Run(run func()) *MockIP4Config_GetPropertySearches_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertySearches_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertySearches_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertySearches_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertySearches_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyWinsServerData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyWinsServerData() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyWinsServerData")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyWinsServerData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWinsServerData'
type MockIP4Config_GetPropertyWinsServerData_Call struct {
*mock.Call
}
// GetPropertyWinsServerData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyWinsServerData() *MockIP4Config_GetPropertyWinsServerData_Call {
return &MockIP4Config_GetPropertyWinsServerData_Call{Call: _e.mock.On("GetPropertyWinsServerData")}
}
func (_c *MockIP4Config_GetPropertyWinsServerData_Call) Run(run func()) *MockIP4Config_GetPropertyWinsServerData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyWinsServerData_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyWinsServerData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyWinsServerData_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyWinsServerData_Call {
_c.Call.Return(run)
return _c
}
// MarshalJSON provides a mock function with no fields
func (_m *MockIP4Config) MarshalJSON() ([]byte, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for MarshalJSON")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func() ([]byte, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []byte); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
type MockIP4Config_MarshalJSON_Call struct {
*mock.Call
}
// MarshalJSON is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) MarshalJSON() *MockIP4Config_MarshalJSON_Call {
return &MockIP4Config_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
}
func (_c *MockIP4Config_MarshalJSON_Call) Run(run func()) *MockIP4Config_MarshalJSON_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockIP4Config_MarshalJSON_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockIP4Config_MarshalJSON_Call {
_c.Call.Return(run)
return _c
}
// NewMockIP4Config creates a new instance of MockIP4Config. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockIP4Config(t interface {
mock.TestingT
Cleanup(func())
}) *MockIP4Config {
mock := &MockIP4Config{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,467 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package gonetworkmanager
import (
gonetworkmanager "github.com/Wifx/gonetworkmanager/v2"
mock "github.com/stretchr/testify/mock"
)
// MockSettings is an autogenerated mock type for the Settings type
type MockSettings struct {
mock.Mock
}
type MockSettings_Expecter struct {
mock *mock.Mock
}
func (_m *MockSettings) EXPECT() *MockSettings_Expecter {
return &MockSettings_Expecter{mock: &_m.Mock}
}
// AddConnection provides a mock function with given fields: settings
func (_m *MockSettings) AddConnection(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) {
ret := _m.Called(settings)
if len(ret) == 0 {
panic("no return value specified for AddConnection")
}
var r0 gonetworkmanager.Connection
var r1 error
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)); ok {
return rf(settings)
}
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) gonetworkmanager.Connection); ok {
r0 = rf(settings)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(gonetworkmanager.Connection)
}
}
if rf, ok := ret.Get(1).(func(gonetworkmanager.ConnectionSettings) error); ok {
r1 = rf(settings)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSettings_AddConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddConnection'
type MockSettings_AddConnection_Call struct {
*mock.Call
}
// AddConnection is a helper method to define mock.On call
// - settings gonetworkmanager.ConnectionSettings
func (_e *MockSettings_Expecter) AddConnection(settings interface{}) *MockSettings_AddConnection_Call {
return &MockSettings_AddConnection_Call{Call: _e.mock.On("AddConnection", settings)}
}
func (_c *MockSettings_AddConnection_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockSettings_AddConnection_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(gonetworkmanager.ConnectionSettings))
})
return _c
}
func (_c *MockSettings_AddConnection_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_AddConnection_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSettings_AddConnection_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)) *MockSettings_AddConnection_Call {
_c.Call.Return(run)
return _c
}
// AddConnectionUnsaved provides a mock function with given fields: settings
func (_m *MockSettings) AddConnectionUnsaved(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) {
ret := _m.Called(settings)
if len(ret) == 0 {
panic("no return value specified for AddConnectionUnsaved")
}
var r0 gonetworkmanager.Connection
var r1 error
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)); ok {
return rf(settings)
}
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) gonetworkmanager.Connection); ok {
r0 = rf(settings)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(gonetworkmanager.Connection)
}
}
if rf, ok := ret.Get(1).(func(gonetworkmanager.ConnectionSettings) error); ok {
r1 = rf(settings)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSettings_AddConnectionUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddConnectionUnsaved'
type MockSettings_AddConnectionUnsaved_Call struct {
*mock.Call
}
// AddConnectionUnsaved is a helper method to define mock.On call
// - settings gonetworkmanager.ConnectionSettings
func (_e *MockSettings_Expecter) AddConnectionUnsaved(settings interface{}) *MockSettings_AddConnectionUnsaved_Call {
return &MockSettings_AddConnectionUnsaved_Call{Call: _e.mock.On("AddConnectionUnsaved", settings)}
}
func (_c *MockSettings_AddConnectionUnsaved_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockSettings_AddConnectionUnsaved_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(gonetworkmanager.ConnectionSettings))
})
return _c
}
func (_c *MockSettings_AddConnectionUnsaved_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_AddConnectionUnsaved_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSettings_AddConnectionUnsaved_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)) *MockSettings_AddConnectionUnsaved_Call {
_c.Call.Return(run)
return _c
}
// GetConnectionByUUID provides a mock function with given fields: uuid
func (_m *MockSettings) GetConnectionByUUID(uuid string) (gonetworkmanager.Connection, error) {
ret := _m.Called(uuid)
if len(ret) == 0 {
panic("no return value specified for GetConnectionByUUID")
}
var r0 gonetworkmanager.Connection
var r1 error
if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.Connection, error)); ok {
return rf(uuid)
}
if rf, ok := ret.Get(0).(func(string) gonetworkmanager.Connection); ok {
r0 = rf(uuid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(gonetworkmanager.Connection)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(uuid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSettings_GetConnectionByUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConnectionByUUID'
type MockSettings_GetConnectionByUUID_Call struct {
*mock.Call
}
// GetConnectionByUUID is a helper method to define mock.On call
// - uuid string
func (_e *MockSettings_Expecter) GetConnectionByUUID(uuid interface{}) *MockSettings_GetConnectionByUUID_Call {
return &MockSettings_GetConnectionByUUID_Call{Call: _e.mock.On("GetConnectionByUUID", uuid)}
}
func (_c *MockSettings_GetConnectionByUUID_Call) Run(run func(uuid string)) *MockSettings_GetConnectionByUUID_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockSettings_GetConnectionByUUID_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_GetConnectionByUUID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSettings_GetConnectionByUUID_Call) RunAndReturn(run func(string) (gonetworkmanager.Connection, error)) *MockSettings_GetConnectionByUUID_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyCanModify provides a mock function with no fields
func (_m *MockSettings) GetPropertyCanModify() (bool, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyCanModify")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func() (bool, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSettings_GetPropertyCanModify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyCanModify'
type MockSettings_GetPropertyCanModify_Call struct {
*mock.Call
}
// GetPropertyCanModify is a helper method to define mock.On call
func (_e *MockSettings_Expecter) GetPropertyCanModify() *MockSettings_GetPropertyCanModify_Call {
return &MockSettings_GetPropertyCanModify_Call{Call: _e.mock.On("GetPropertyCanModify")}
}
func (_c *MockSettings_GetPropertyCanModify_Call) Run(run func()) *MockSettings_GetPropertyCanModify_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockSettings_GetPropertyCanModify_Call) Return(_a0 bool, _a1 error) *MockSettings_GetPropertyCanModify_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSettings_GetPropertyCanModify_Call) RunAndReturn(run func() (bool, error)) *MockSettings_GetPropertyCanModify_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyHostname provides a mock function with no fields
func (_m *MockSettings) GetPropertyHostname() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyHostname")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSettings_GetPropertyHostname_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHostname'
type MockSettings_GetPropertyHostname_Call struct {
*mock.Call
}
// GetPropertyHostname is a helper method to define mock.On call
func (_e *MockSettings_Expecter) GetPropertyHostname() *MockSettings_GetPropertyHostname_Call {
return &MockSettings_GetPropertyHostname_Call{Call: _e.mock.On("GetPropertyHostname")}
}
func (_c *MockSettings_GetPropertyHostname_Call) Run(run func()) *MockSettings_GetPropertyHostname_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockSettings_GetPropertyHostname_Call) Return(_a0 string, _a1 error) *MockSettings_GetPropertyHostname_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSettings_GetPropertyHostname_Call) RunAndReturn(run func() (string, error)) *MockSettings_GetPropertyHostname_Call {
_c.Call.Return(run)
return _c
}
// ListConnections provides a mock function with no fields
func (_m *MockSettings) ListConnections() ([]gonetworkmanager.Connection, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for ListConnections")
}
var r0 []gonetworkmanager.Connection
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Connection, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.Connection); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.Connection)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSettings_ListConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListConnections'
type MockSettings_ListConnections_Call struct {
*mock.Call
}
// ListConnections is a helper method to define mock.On call
func (_e *MockSettings_Expecter) ListConnections() *MockSettings_ListConnections_Call {
return &MockSettings_ListConnections_Call{Call: _e.mock.On("ListConnections")}
}
func (_c *MockSettings_ListConnections_Call) Run(run func()) *MockSettings_ListConnections_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockSettings_ListConnections_Call) Return(_a0 []gonetworkmanager.Connection, _a1 error) *MockSettings_ListConnections_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSettings_ListConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.Connection, error)) *MockSettings_ListConnections_Call {
_c.Call.Return(run)
return _c
}
// ReloadConnections provides a mock function with no fields
func (_m *MockSettings) ReloadConnections() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for ReloadConnections")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockSettings_ReloadConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReloadConnections'
type MockSettings_ReloadConnections_Call struct {
*mock.Call
}
// ReloadConnections is a helper method to define mock.On call
func (_e *MockSettings_Expecter) ReloadConnections() *MockSettings_ReloadConnections_Call {
return &MockSettings_ReloadConnections_Call{Call: _e.mock.On("ReloadConnections")}
}
func (_c *MockSettings_ReloadConnections_Call) Run(run func()) *MockSettings_ReloadConnections_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockSettings_ReloadConnections_Call) Return(_a0 error) *MockSettings_ReloadConnections_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockSettings_ReloadConnections_Call) RunAndReturn(run func() error) *MockSettings_ReloadConnections_Call {
_c.Call.Return(run)
return _c
}
// SaveHostname provides a mock function with given fields: hostname
func (_m *MockSettings) SaveHostname(hostname string) error {
ret := _m.Called(hostname)
if len(ret) == 0 {
panic("no return value specified for SaveHostname")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(hostname)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockSettings_SaveHostname_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveHostname'
type MockSettings_SaveHostname_Call struct {
*mock.Call
}
// SaveHostname is a helper method to define mock.On call
// - hostname string
func (_e *MockSettings_Expecter) SaveHostname(hostname interface{}) *MockSettings_SaveHostname_Call {
return &MockSettings_SaveHostname_Call{Call: _e.mock.On("SaveHostname", hostname)}
}
func (_c *MockSettings_SaveHostname_Call) Run(run func(hostname string)) *MockSettings_SaveHostname_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockSettings_SaveHostname_Call) Return(_a0 error) *MockSettings_SaveHostname_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockSettings_SaveHostname_Call) RunAndReturn(run func(string) error) *MockSettings_SaveHostname_Call {
_c.Call.Return(run)
return _c
}
// NewMockSettings creates a new instance of MockSettings. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockSettings(t interface {
mock.TestingT
Cleanup(func())
}) *MockSettings {
mock := &MockSettings{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,649 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package dbus
import (
context "context"
dbus "github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// MockBusObject is an autogenerated mock type for the BusObject type
type MockBusObject struct {
mock.Mock
}
type MockBusObject_Expecter struct {
mock *mock.Mock
}
func (_m *MockBusObject) EXPECT() *MockBusObject_Expecter {
return &MockBusObject_Expecter{mock: &_m.Mock}
}
// AddMatchSignal provides a mock function with given fields: iface, member, options
func (_m *MockBusObject) AddMatchSignal(iface string, member string, options ...dbus.MatchOption) *dbus.Call {
_va := make([]interface{}, len(options))
for _i := range options {
_va[_i] = options[_i]
}
var _ca []interface{}
_ca = append(_ca, iface, member)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for AddMatchSignal")
}
var r0 *dbus.Call
if rf, ok := ret.Get(0).(func(string, string, ...dbus.MatchOption) *dbus.Call); ok {
r0 = rf(iface, member, options...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*dbus.Call)
}
}
return r0
}
// MockBusObject_AddMatchSignal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddMatchSignal'
type MockBusObject_AddMatchSignal_Call struct {
*mock.Call
}
// AddMatchSignal is a helper method to define mock.On call
// - iface string
// - member string
// - options ...dbus.MatchOption
func (_e *MockBusObject_Expecter) AddMatchSignal(iface interface{}, member interface{}, options ...interface{}) *MockBusObject_AddMatchSignal_Call {
return &MockBusObject_AddMatchSignal_Call{Call: _e.mock.On("AddMatchSignal",
append([]interface{}{iface, member}, options...)...)}
}
func (_c *MockBusObject_AddMatchSignal_Call) Run(run func(iface string, member string, options ...dbus.MatchOption)) *MockBusObject_AddMatchSignal_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]dbus.MatchOption, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(dbus.MatchOption)
}
}
run(args[0].(string), args[1].(string), variadicArgs...)
})
return _c
}
func (_c *MockBusObject_AddMatchSignal_Call) Return(_a0 *dbus.Call) *MockBusObject_AddMatchSignal_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_AddMatchSignal_Call) RunAndReturn(run func(string, string, ...dbus.MatchOption) *dbus.Call) *MockBusObject_AddMatchSignal_Call {
_c.Call.Return(run)
return _c
}
// Call provides a mock function with given fields: method, flags, args
func (_m *MockBusObject) Call(method string, flags dbus.Flags, args ...interface{}) *dbus.Call {
var _ca []interface{}
_ca = append(_ca, method, flags)
_ca = append(_ca, args...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for Call")
}
var r0 *dbus.Call
if rf, ok := ret.Get(0).(func(string, dbus.Flags, ...interface{}) *dbus.Call); ok {
r0 = rf(method, flags, args...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*dbus.Call)
}
}
return r0
}
// MockBusObject_Call_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Call'
type MockBusObject_Call_Call struct {
*mock.Call
}
// Call is a helper method to define mock.On call
// - method string
// - flags dbus.Flags
// - args ...interface{}
func (_e *MockBusObject_Expecter) Call(method interface{}, flags interface{}, args ...interface{}) *MockBusObject_Call_Call {
return &MockBusObject_Call_Call{Call: _e.mock.On("Call",
append([]interface{}{method, flags}, args...)...)}
}
func (_c *MockBusObject_Call_Call) Run(run func(method string, flags dbus.Flags, args ...interface{})) *MockBusObject_Call_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]interface{}, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(interface{})
}
}
run(args[0].(string), args[1].(dbus.Flags), variadicArgs...)
})
return _c
}
func (_c *MockBusObject_Call_Call) Return(_a0 *dbus.Call) *MockBusObject_Call_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_Call_Call) RunAndReturn(run func(string, dbus.Flags, ...interface{}) *dbus.Call) *MockBusObject_Call_Call {
_c.Call.Return(run)
return _c
}
// CallWithContext provides a mock function with given fields: ctx, method, flags, args
func (_m *MockBusObject) CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...interface{}) *dbus.Call {
var _ca []interface{}
_ca = append(_ca, ctx, method, flags)
_ca = append(_ca, args...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for CallWithContext")
}
var r0 *dbus.Call
if rf, ok := ret.Get(0).(func(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call); ok {
r0 = rf(ctx, method, flags, args...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*dbus.Call)
}
}
return r0
}
// MockBusObject_CallWithContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CallWithContext'
type MockBusObject_CallWithContext_Call struct {
*mock.Call
}
// CallWithContext is a helper method to define mock.On call
// - ctx context.Context
// - method string
// - flags dbus.Flags
// - args ...interface{}
func (_e *MockBusObject_Expecter) CallWithContext(ctx interface{}, method interface{}, flags interface{}, args ...interface{}) *MockBusObject_CallWithContext_Call {
return &MockBusObject_CallWithContext_Call{Call: _e.mock.On("CallWithContext",
append([]interface{}{ctx, method, flags}, args...)...)}
}
func (_c *MockBusObject_CallWithContext_Call) Run(run func(ctx context.Context, method string, flags dbus.Flags, args ...interface{})) *MockBusObject_CallWithContext_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]interface{}, len(args)-3)
for i, a := range args[3:] {
if a != nil {
variadicArgs[i] = a.(interface{})
}
}
run(args[0].(context.Context), args[1].(string), args[2].(dbus.Flags), variadicArgs...)
})
return _c
}
func (_c *MockBusObject_CallWithContext_Call) Return(_a0 *dbus.Call) *MockBusObject_CallWithContext_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_CallWithContext_Call) RunAndReturn(run func(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call) *MockBusObject_CallWithContext_Call {
_c.Call.Return(run)
return _c
}
// Destination provides a mock function with no fields
func (_m *MockBusObject) Destination() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Destination")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockBusObject_Destination_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Destination'
type MockBusObject_Destination_Call struct {
*mock.Call
}
// Destination is a helper method to define mock.On call
func (_e *MockBusObject_Expecter) Destination() *MockBusObject_Destination_Call {
return &MockBusObject_Destination_Call{Call: _e.mock.On("Destination")}
}
func (_c *MockBusObject_Destination_Call) Run(run func()) *MockBusObject_Destination_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockBusObject_Destination_Call) Return(_a0 string) *MockBusObject_Destination_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_Destination_Call) RunAndReturn(run func() string) *MockBusObject_Destination_Call {
_c.Call.Return(run)
return _c
}
// GetProperty provides a mock function with given fields: p
func (_m *MockBusObject) GetProperty(p string) (dbus.Variant, error) {
ret := _m.Called(p)
if len(ret) == 0 {
panic("no return value specified for GetProperty")
}
var r0 dbus.Variant
var r1 error
if rf, ok := ret.Get(0).(func(string) (dbus.Variant, error)); ok {
return rf(p)
}
if rf, ok := ret.Get(0).(func(string) dbus.Variant); ok {
r0 = rf(p)
} else {
r0 = ret.Get(0).(dbus.Variant)
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(p)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockBusObject_GetProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProperty'
type MockBusObject_GetProperty_Call struct {
*mock.Call
}
// GetProperty is a helper method to define mock.On call
// - p string
func (_e *MockBusObject_Expecter) GetProperty(p interface{}) *MockBusObject_GetProperty_Call {
return &MockBusObject_GetProperty_Call{Call: _e.mock.On("GetProperty", p)}
}
func (_c *MockBusObject_GetProperty_Call) Run(run func(p string)) *MockBusObject_GetProperty_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockBusObject_GetProperty_Call) Return(_a0 dbus.Variant, _a1 error) *MockBusObject_GetProperty_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockBusObject_GetProperty_Call) RunAndReturn(run func(string) (dbus.Variant, error)) *MockBusObject_GetProperty_Call {
_c.Call.Return(run)
return _c
}
// Go provides a mock function with given fields: method, flags, ch, args
func (_m *MockBusObject) Go(method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{}) *dbus.Call {
var _ca []interface{}
_ca = append(_ca, method, flags, ch)
_ca = append(_ca, args...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for Go")
}
var r0 *dbus.Call
if rf, ok := ret.Get(0).(func(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call); ok {
r0 = rf(method, flags, ch, args...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*dbus.Call)
}
}
return r0
}
// MockBusObject_Go_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Go'
type MockBusObject_Go_Call struct {
*mock.Call
}
// Go is a helper method to define mock.On call
// - method string
// - flags dbus.Flags
// - ch chan *dbus.Call
// - args ...interface{}
func (_e *MockBusObject_Expecter) Go(method interface{}, flags interface{}, ch interface{}, args ...interface{}) *MockBusObject_Go_Call {
return &MockBusObject_Go_Call{Call: _e.mock.On("Go",
append([]interface{}{method, flags, ch}, args...)...)}
}
func (_c *MockBusObject_Go_Call) Run(run func(method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{})) *MockBusObject_Go_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]interface{}, len(args)-3)
for i, a := range args[3:] {
if a != nil {
variadicArgs[i] = a.(interface{})
}
}
run(args[0].(string), args[1].(dbus.Flags), args[2].(chan *dbus.Call), variadicArgs...)
})
return _c
}
func (_c *MockBusObject_Go_Call) Return(_a0 *dbus.Call) *MockBusObject_Go_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_Go_Call) RunAndReturn(run func(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call) *MockBusObject_Go_Call {
_c.Call.Return(run)
return _c
}
// GoWithContext provides a mock function with given fields: ctx, method, flags, ch, args
func (_m *MockBusObject) GoWithContext(ctx context.Context, method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{}) *dbus.Call {
var _ca []interface{}
_ca = append(_ca, ctx, method, flags, ch)
_ca = append(_ca, args...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for GoWithContext")
}
var r0 *dbus.Call
if rf, ok := ret.Get(0).(func(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call); ok {
r0 = rf(ctx, method, flags, ch, args...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*dbus.Call)
}
}
return r0
}
// MockBusObject_GoWithContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GoWithContext'
type MockBusObject_GoWithContext_Call struct {
*mock.Call
}
// GoWithContext is a helper method to define mock.On call
// - ctx context.Context
// - method string
// - flags dbus.Flags
// - ch chan *dbus.Call
// - args ...interface{}
func (_e *MockBusObject_Expecter) GoWithContext(ctx interface{}, method interface{}, flags interface{}, ch interface{}, args ...interface{}) *MockBusObject_GoWithContext_Call {
return &MockBusObject_GoWithContext_Call{Call: _e.mock.On("GoWithContext",
append([]interface{}{ctx, method, flags, ch}, args...)...)}
}
func (_c *MockBusObject_GoWithContext_Call) Run(run func(ctx context.Context, method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{})) *MockBusObject_GoWithContext_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]interface{}, len(args)-4)
for i, a := range args[4:] {
if a != nil {
variadicArgs[i] = a.(interface{})
}
}
run(args[0].(context.Context), args[1].(string), args[2].(dbus.Flags), args[3].(chan *dbus.Call), variadicArgs...)
})
return _c
}
func (_c *MockBusObject_GoWithContext_Call) Return(_a0 *dbus.Call) *MockBusObject_GoWithContext_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_GoWithContext_Call) RunAndReturn(run func(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call) *MockBusObject_GoWithContext_Call {
_c.Call.Return(run)
return _c
}
// Path provides a mock function with no fields
func (_m *MockBusObject) Path() dbus.ObjectPath {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Path")
}
var r0 dbus.ObjectPath
if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(dbus.ObjectPath)
}
return r0
}
// MockBusObject_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path'
type MockBusObject_Path_Call struct {
*mock.Call
}
// Path is a helper method to define mock.On call
func (_e *MockBusObject_Expecter) Path() *MockBusObject_Path_Call {
return &MockBusObject_Path_Call{Call: _e.mock.On("Path")}
}
func (_c *MockBusObject_Path_Call) Run(run func()) *MockBusObject_Path_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockBusObject_Path_Call) Return(_a0 dbus.ObjectPath) *MockBusObject_Path_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_Path_Call) RunAndReturn(run func() dbus.ObjectPath) *MockBusObject_Path_Call {
_c.Call.Return(run)
return _c
}
// RemoveMatchSignal provides a mock function with given fields: iface, member, options
func (_m *MockBusObject) RemoveMatchSignal(iface string, member string, options ...dbus.MatchOption) *dbus.Call {
_va := make([]interface{}, len(options))
for _i := range options {
_va[_i] = options[_i]
}
var _ca []interface{}
_ca = append(_ca, iface, member)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for RemoveMatchSignal")
}
var r0 *dbus.Call
if rf, ok := ret.Get(0).(func(string, string, ...dbus.MatchOption) *dbus.Call); ok {
r0 = rf(iface, member, options...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*dbus.Call)
}
}
return r0
}
// MockBusObject_RemoveMatchSignal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveMatchSignal'
type MockBusObject_RemoveMatchSignal_Call struct {
*mock.Call
}
// RemoveMatchSignal is a helper method to define mock.On call
// - iface string
// - member string
// - options ...dbus.MatchOption
func (_e *MockBusObject_Expecter) RemoveMatchSignal(iface interface{}, member interface{}, options ...interface{}) *MockBusObject_RemoveMatchSignal_Call {
return &MockBusObject_RemoveMatchSignal_Call{Call: _e.mock.On("RemoveMatchSignal",
append([]interface{}{iface, member}, options...)...)}
}
func (_c *MockBusObject_RemoveMatchSignal_Call) Run(run func(iface string, member string, options ...dbus.MatchOption)) *MockBusObject_RemoveMatchSignal_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]dbus.MatchOption, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(dbus.MatchOption)
}
}
run(args[0].(string), args[1].(string), variadicArgs...)
})
return _c
}
func (_c *MockBusObject_RemoveMatchSignal_Call) Return(_a0 *dbus.Call) *MockBusObject_RemoveMatchSignal_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_RemoveMatchSignal_Call) RunAndReturn(run func(string, string, ...dbus.MatchOption) *dbus.Call) *MockBusObject_RemoveMatchSignal_Call {
_c.Call.Return(run)
return _c
}
// SetProperty provides a mock function with given fields: p, v
func (_m *MockBusObject) SetProperty(p string, v interface{}) error {
ret := _m.Called(p, v)
if len(ret) == 0 {
panic("no return value specified for SetProperty")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
r0 = rf(p, v)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockBusObject_SetProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetProperty'
type MockBusObject_SetProperty_Call struct {
*mock.Call
}
// SetProperty is a helper method to define mock.On call
// - p string
// - v interface{}
func (_e *MockBusObject_Expecter) SetProperty(p interface{}, v interface{}) *MockBusObject_SetProperty_Call {
return &MockBusObject_SetProperty_Call{Call: _e.mock.On("SetProperty", p, v)}
}
func (_c *MockBusObject_SetProperty_Call) Run(run func(p string, v interface{})) *MockBusObject_SetProperty_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(interface{}))
})
return _c
}
func (_c *MockBusObject_SetProperty_Call) Return(_a0 error) *MockBusObject_SetProperty_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_SetProperty_Call) RunAndReturn(run func(string, interface{}) error) *MockBusObject_SetProperty_Call {
_c.Call.Return(run)
return _c
}
// StoreProperty provides a mock function with given fields: p, value
func (_m *MockBusObject) StoreProperty(p string, value interface{}) error {
ret := _m.Called(p, value)
if len(ret) == 0 {
panic("no return value specified for StoreProperty")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
r0 = rf(p, value)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockBusObject_StoreProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StoreProperty'
type MockBusObject_StoreProperty_Call struct {
*mock.Call
}
// StoreProperty is a helper method to define mock.On call
// - p string
// - value interface{}
func (_e *MockBusObject_Expecter) StoreProperty(p interface{}, value interface{}) *MockBusObject_StoreProperty_Call {
return &MockBusObject_StoreProperty_Call{Call: _e.mock.On("StoreProperty", p, value)}
}
func (_c *MockBusObject_StoreProperty_Call) Run(run func(p string, value interface{})) *MockBusObject_StoreProperty_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(interface{}))
})
return _c
}
func (_c *MockBusObject_StoreProperty_Call) Return(_a0 error) *MockBusObject_StoreProperty_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockBusObject_StoreProperty_Call) RunAndReturn(run func(string, interface{}) error) *MockBusObject_StoreProperty_Call {
_c.Call.Return(run)
return _c
}
// NewMockBusObject creates a new instance of MockBusObject. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockBusObject(t interface {
mock.TestingT
Cleanup(func())
}) *MockBusObject {
mock := &MockBusObject{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,181 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package plugins
import mock "github.com/stretchr/testify/mock"
// MockGitClient is an autogenerated mock type for the GitClient type
type MockGitClient struct {
mock.Mock
}
type MockGitClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockGitClient) EXPECT() *MockGitClient_Expecter {
return &MockGitClient_Expecter{mock: &_m.Mock}
}
// HasUpdates provides a mock function with given fields: path
func (_m *MockGitClient) HasUpdates(path string) (bool, error) {
ret := _m.Called(path)
if len(ret) == 0 {
panic("no return value specified for HasUpdates")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(string) (bool, error)); ok {
return rf(path)
}
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(path)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(path)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGitClient_HasUpdates_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasUpdates'
type MockGitClient_HasUpdates_Call struct {
*mock.Call
}
// HasUpdates is a helper method to define mock.On call
// - path string
func (_e *MockGitClient_Expecter) HasUpdates(path interface{}) *MockGitClient_HasUpdates_Call {
return &MockGitClient_HasUpdates_Call{Call: _e.mock.On("HasUpdates", path)}
}
func (_c *MockGitClient_HasUpdates_Call) Run(run func(path string)) *MockGitClient_HasUpdates_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockGitClient_HasUpdates_Call) Return(_a0 bool, _a1 error) *MockGitClient_HasUpdates_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitClient_HasUpdates_Call) RunAndReturn(run func(string) (bool, error)) *MockGitClient_HasUpdates_Call {
_c.Call.Return(run)
return _c
}
// PlainClone provides a mock function with given fields: path, url
func (_m *MockGitClient) PlainClone(path string, url string) error {
ret := _m.Called(path, url)
if len(ret) == 0 {
panic("no return value specified for PlainClone")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(path, url)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockGitClient_PlainClone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PlainClone'
type MockGitClient_PlainClone_Call struct {
*mock.Call
}
// PlainClone is a helper method to define mock.On call
// - path string
// - url string
func (_e *MockGitClient_Expecter) PlainClone(path interface{}, url interface{}) *MockGitClient_PlainClone_Call {
return &MockGitClient_PlainClone_Call{Call: _e.mock.On("PlainClone", path, url)}
}
func (_c *MockGitClient_PlainClone_Call) Run(run func(path string, url string)) *MockGitClient_PlainClone_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(string))
})
return _c
}
func (_c *MockGitClient_PlainClone_Call) Return(_a0 error) *MockGitClient_PlainClone_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockGitClient_PlainClone_Call) RunAndReturn(run func(string, string) error) *MockGitClient_PlainClone_Call {
_c.Call.Return(run)
return _c
}
// Pull provides a mock function with given fields: path
func (_m *MockGitClient) Pull(path string) error {
ret := _m.Called(path)
if len(ret) == 0 {
panic("no return value specified for Pull")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(path)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockGitClient_Pull_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pull'
type MockGitClient_Pull_Call struct {
*mock.Call
}
// Pull is a helper method to define mock.On call
// - path string
func (_e *MockGitClient_Expecter) Pull(path interface{}) *MockGitClient_Pull_Call {
return &MockGitClient_Pull_Call{Call: _e.mock.On("Pull", path)}
}
func (_c *MockGitClient_Pull_Call) Run(run func(path string)) *MockGitClient_Pull_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockGitClient_Pull_Call) Return(_a0 error) *MockGitClient_Pull_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockGitClient_Pull_Call) RunAndReturn(run func(string) error) *MockGitClient_Pull_Call {
_c.Call.Return(run)
return _c
}
// NewMockGitClient creates a new instance of MockGitClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockGitClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockGitClient {
mock := &MockGitClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,427 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package net
import (
net "net"
mock "github.com/stretchr/testify/mock"
time "time"
)
// MockConn is an autogenerated mock type for the Conn type
type MockConn struct {
mock.Mock
}
type MockConn_Expecter struct {
mock *mock.Mock
}
func (_m *MockConn) EXPECT() *MockConn_Expecter {
return &MockConn_Expecter{mock: &_m.Mock}
}
// Close provides a mock function with no fields
func (_m *MockConn) Close() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Close")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
type MockConn_Close_Call struct {
*mock.Call
}
// Close is a helper method to define mock.On call
func (_e *MockConn_Expecter) Close() *MockConn_Close_Call {
return &MockConn_Close_Call{Call: _e.mock.On("Close")}
}
func (_c *MockConn_Close_Call) Run(run func()) *MockConn_Close_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConn_Close_Call) Return(_a0 error) *MockConn_Close_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConn_Close_Call) RunAndReturn(run func() error) *MockConn_Close_Call {
_c.Call.Return(run)
return _c
}
// LocalAddr provides a mock function with no fields
func (_m *MockConn) LocalAddr() net.Addr {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for LocalAddr")
}
var r0 net.Addr
if rf, ok := ret.Get(0).(func() net.Addr); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(net.Addr)
}
}
return r0
}
// MockConn_LocalAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LocalAddr'
type MockConn_LocalAddr_Call struct {
*mock.Call
}
// LocalAddr is a helper method to define mock.On call
func (_e *MockConn_Expecter) LocalAddr() *MockConn_LocalAddr_Call {
return &MockConn_LocalAddr_Call{Call: _e.mock.On("LocalAddr")}
}
func (_c *MockConn_LocalAddr_Call) Run(run func()) *MockConn_LocalAddr_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConn_LocalAddr_Call) Return(_a0 net.Addr) *MockConn_LocalAddr_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConn_LocalAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_LocalAddr_Call {
_c.Call.Return(run)
return _c
}
// Read provides a mock function with given fields: b
func (_m *MockConn) Read(b []byte) (int, error) {
ret := _m.Called(b)
if len(ret) == 0 {
panic("no return value specified for Read")
}
var r0 int
var r1 error
if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok {
return rf(b)
}
if rf, ok := ret.Get(0).(func([]byte) int); ok {
r0 = rf(b)
} else {
r0 = ret.Get(0).(int)
}
if rf, ok := ret.Get(1).(func([]byte) error); ok {
r1 = rf(b)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConn_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
type MockConn_Read_Call struct {
*mock.Call
}
// Read is a helper method to define mock.On call
// - b []byte
func (_e *MockConn_Expecter) Read(b interface{}) *MockConn_Read_Call {
return &MockConn_Read_Call{Call: _e.mock.On("Read", b)}
}
func (_c *MockConn_Read_Call) Run(run func(b []byte)) *MockConn_Read_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].([]byte))
})
return _c
}
func (_c *MockConn_Read_Call) Return(n int, err error) *MockConn_Read_Call {
_c.Call.Return(n, err)
return _c
}
func (_c *MockConn_Read_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Read_Call {
_c.Call.Return(run)
return _c
}
// RemoteAddr provides a mock function with no fields
func (_m *MockConn) RemoteAddr() net.Addr {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for RemoteAddr")
}
var r0 net.Addr
if rf, ok := ret.Get(0).(func() net.Addr); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(net.Addr)
}
}
return r0
}
// MockConn_RemoteAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoteAddr'
type MockConn_RemoteAddr_Call struct {
*mock.Call
}
// RemoteAddr is a helper method to define mock.On call
func (_e *MockConn_Expecter) RemoteAddr() *MockConn_RemoteAddr_Call {
return &MockConn_RemoteAddr_Call{Call: _e.mock.On("RemoteAddr")}
}
func (_c *MockConn_RemoteAddr_Call) Run(run func()) *MockConn_RemoteAddr_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConn_RemoteAddr_Call) Return(_a0 net.Addr) *MockConn_RemoteAddr_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConn_RemoteAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_RemoteAddr_Call {
_c.Call.Return(run)
return _c
}
// SetDeadline provides a mock function with given fields: t
func (_m *MockConn) SetDeadline(t time.Time) error {
ret := _m.Called(t)
if len(ret) == 0 {
panic("no return value specified for SetDeadline")
}
var r0 error
if rf, ok := ret.Get(0).(func(time.Time) error); ok {
r0 = rf(t)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConn_SetDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDeadline'
type MockConn_SetDeadline_Call struct {
*mock.Call
}
// SetDeadline is a helper method to define mock.On call
// - t time.Time
func (_e *MockConn_Expecter) SetDeadline(t interface{}) *MockConn_SetDeadline_Call {
return &MockConn_SetDeadline_Call{Call: _e.mock.On("SetDeadline", t)}
}
func (_c *MockConn_SetDeadline_Call) Run(run func(t time.Time)) *MockConn_SetDeadline_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(time.Time))
})
return _c
}
func (_c *MockConn_SetDeadline_Call) Return(_a0 error) *MockConn_SetDeadline_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConn_SetDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetDeadline_Call {
_c.Call.Return(run)
return _c
}
// SetReadDeadline provides a mock function with given fields: t
func (_m *MockConn) SetReadDeadline(t time.Time) error {
ret := _m.Called(t)
if len(ret) == 0 {
panic("no return value specified for SetReadDeadline")
}
var r0 error
if rf, ok := ret.Get(0).(func(time.Time) error); ok {
r0 = rf(t)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConn_SetReadDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetReadDeadline'
type MockConn_SetReadDeadline_Call struct {
*mock.Call
}
// SetReadDeadline is a helper method to define mock.On call
// - t time.Time
func (_e *MockConn_Expecter) SetReadDeadline(t interface{}) *MockConn_SetReadDeadline_Call {
return &MockConn_SetReadDeadline_Call{Call: _e.mock.On("SetReadDeadline", t)}
}
func (_c *MockConn_SetReadDeadline_Call) Run(run func(t time.Time)) *MockConn_SetReadDeadline_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(time.Time))
})
return _c
}
func (_c *MockConn_SetReadDeadline_Call) Return(_a0 error) *MockConn_SetReadDeadline_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConn_SetReadDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetReadDeadline_Call {
_c.Call.Return(run)
return _c
}
// SetWriteDeadline provides a mock function with given fields: t
func (_m *MockConn) SetWriteDeadline(t time.Time) error {
ret := _m.Called(t)
if len(ret) == 0 {
panic("no return value specified for SetWriteDeadline")
}
var r0 error
if rf, ok := ret.Get(0).(func(time.Time) error); ok {
r0 = rf(t)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConn_SetWriteDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWriteDeadline'
type MockConn_SetWriteDeadline_Call struct {
*mock.Call
}
// SetWriteDeadline is a helper method to define mock.On call
// - t time.Time
func (_e *MockConn_Expecter) SetWriteDeadline(t interface{}) *MockConn_SetWriteDeadline_Call {
return &MockConn_SetWriteDeadline_Call{Call: _e.mock.On("SetWriteDeadline", t)}
}
func (_c *MockConn_SetWriteDeadline_Call) Run(run func(t time.Time)) *MockConn_SetWriteDeadline_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(time.Time))
})
return _c
}
func (_c *MockConn_SetWriteDeadline_Call) Return(_a0 error) *MockConn_SetWriteDeadline_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConn_SetWriteDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetWriteDeadline_Call {
_c.Call.Return(run)
return _c
}
// Write provides a mock function with given fields: b
func (_m *MockConn) Write(b []byte) (int, error) {
ret := _m.Called(b)
if len(ret) == 0 {
panic("no return value specified for Write")
}
var r0 int
var r1 error
if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok {
return rf(b)
}
if rf, ok := ret.Get(0).(func([]byte) int); ok {
r0 = rf(b)
} else {
r0 = ret.Get(0).(int)
}
if rf, ok := ret.Get(1).(func([]byte) error); ok {
r1 = rf(b)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConn_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
type MockConn_Write_Call struct {
*mock.Call
}
// Write is a helper method to define mock.On call
// - b []byte
func (_e *MockConn_Expecter) Write(b interface{}) *MockConn_Write_Call {
return &MockConn_Write_Call{Call: _e.mock.On("Write", b)}
}
func (_c *MockConn_Write_Call) Run(run func(b []byte)) *MockConn_Write_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].([]byte))
})
return _c
}
func (_c *MockConn_Write_Call) Return(n int, err error) *MockConn_Write_Call {
_c.Call.Return(n, err)
return _c
}
func (_c *MockConn_Write_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Write_Call {
_c.Call.Return(run)
return _c
}
// NewMockConn creates a new instance of MockConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockConn(t interface {
mock.TestingT
Cleanup(func())
}) *MockConn {
mock := &MockConn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

File diff suppressed because it is too large Load Diff

430
internal/plugins/manager.go Normal file
View File

@@ -0,0 +1,430 @@
package plugins
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/afero"
)
type Manager struct {
fs afero.Fs
pluginsDir string
gitClient GitClient
}
func NewManager() (*Manager, error) {
return NewManagerWithFs(afero.NewOsFs())
}
func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
pluginsDir := getPluginsDir()
return &Manager{
fs: fs,
pluginsDir: pluginsDir,
gitClient: &realGitClient{},
}, nil
}
func getPluginsDir() string {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return filepath.Join(os.TempDir(), "DankMaterialShell", "plugins")
}
configHome = filepath.Join(homeDir, ".config")
}
return filepath.Join(configHome, "DankMaterialShell", "plugins")
}
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return false, err
}
if exists {
return true, nil
}
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return false, err
}
return systemExists, nil
}
func (m *Manager) Install(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if exists {
return fmt.Errorf("plugin already installed: %s", plugin.Name)
}
if err := m.fs.MkdirAll(m.pluginsDir, 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
reposDir := filepath.Join(m.pluginsDir, ".repos")
if err := m.fs.MkdirAll(reposDir, 0755); err != nil {
return fmt.Errorf("failed to create repos directory: %w", err)
}
if plugin.Path != "" {
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
repoExists, err := afero.DirExists(m.fs, repoPath)
if err != nil {
return fmt.Errorf("failed to check if repo exists: %w", err)
}
if !repoExists {
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
m.fs.RemoveAll(repoPath)
return fmt.Errorf("failed to clone repository: %w", err)
}
} else {
// Pull latest changes if repo already exists
if err := m.gitClient.Pull(repoPath); err != nil {
// If pull fails (e.g., corrupted shallow clone), delete and re-clone
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to remove corrupted repository: %w", err)
}
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone repository: %w", err)
}
}
}
sourcePath := filepath.Join(repoPath, plugin.Path)
sourceExists, err := afero.DirExists(m.fs, sourcePath)
if err != nil {
return fmt.Errorf("failed to check plugin path: %w", err)
}
if !sourceExists {
return fmt.Errorf("plugin path does not exist in repository: %s", plugin.Path)
}
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
metaPath := pluginPath + ".meta"
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0644); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
} else {
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
m.fs.RemoveAll(pluginPath)
return fmt.Errorf("failed to clone plugin: %w", err)
}
}
return nil
}
func (m *Manager) getRepoName(repoURL string) string {
hash := sha256.Sum256([]byte(repoURL))
return hex.EncodeToString(hash[:])[:16]
}
func (m *Manager) createSymlink(source, dest string) error {
if symlinkFs, ok := m.fs.(afero.Symlinker); ok {
return symlinkFs.SymlinkIfPossible(source, dest)
}
return os.Symlink(source, dest)
}
func (m *Manager) Update(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if !exists {
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if systemExists {
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
}
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := m.gitClient.Pull(repoPath); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to remove corrupted repository: %w", err)
}
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone repository: %w", err)
}
}
} else {
// Try to pull, if it fails, delete and re-clone
if err := m.gitClient.Pull(pluginPath); err != nil {
if err := m.fs.RemoveAll(pluginPath); err != nil {
return fmt.Errorf("failed to remove corrupted plugin: %w", err)
}
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone plugin: %w", err)
}
}
}
return nil
}
func (m *Manager) Uninstall(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if !exists {
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if systemExists {
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
}
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
if err != nil {
return fmt.Errorf("failed to check repo cleanup: %w", err)
}
if err := m.fs.Remove(pluginPath); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
if err := m.fs.Remove(metaPath); err != nil {
return fmt.Errorf("failed to remove metadata: %w", err)
}
if shouldCleanup {
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to cleanup repository: %w", err)
}
}
} else {
if err := m.fs.RemoveAll(pluginPath); err != nil {
return fmt.Errorf("failed to remove plugin: %w", err)
}
}
return nil
}
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
installed, err := m.ListInstalled()
if err != nil {
return false, err
}
registry, err := NewRegistry()
if err != nil {
return false, err
}
allPlugins, err := registry.List()
if err != nil {
return false, err
}
for _, id := range installed {
if id == excludePlugin {
continue
}
for _, p := range allPlugins {
if p.ID == id && p.Repo == repoURL && p.Path != "" {
return false, nil
}
}
}
return true, nil
}
func (m *Manager) ListInstalled() ([]string, error) {
installedMap := make(map[string]bool)
exists, err := afero.DirExists(m.fs, m.pluginsDir)
if err != nil {
return nil, err
}
if exists {
entries, err := afero.ReadDir(m.fs, m.pluginsDir)
if err != nil {
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
}
for _, entry := range entries {
name := entry.Name()
if name == ".repos" || strings.HasSuffix(name, ".meta") {
continue
}
fullPath := filepath.Join(m.pluginsDir, name)
isPlugin := false
if entry.IsDir() {
isPlugin = true
} else if entry.Mode()&os.ModeSymlink != 0 {
isPlugin = true
} else {
info, err := m.fs.Stat(fullPath)
if err == nil && info.IsDir() {
isPlugin = true
}
}
if isPlugin {
// Read plugin.json to get the actual plugin ID
pluginID := m.getPluginID(fullPath)
if pluginID != "" {
installedMap[pluginID] = true
}
}
}
}
systemPluginsDir := "/etc/xdg/quickshell/dms-plugins"
systemExists, err := afero.DirExists(m.fs, systemPluginsDir)
if err == nil && systemExists {
entries, err := afero.ReadDir(m.fs, systemPluginsDir)
if err == nil {
for _, entry := range entries {
if entry.IsDir() {
fullPath := filepath.Join(systemPluginsDir, entry.Name())
// Read plugin.json to get the actual plugin ID
pluginID := m.getPluginID(fullPath)
if pluginID != "" {
installedMap[pluginID] = true
}
}
}
}
}
var installed []string
for name := range installedMap {
installed = append(installed, name)
}
return installed, nil
}
// getPluginID reads the plugin.json file and returns the plugin ID
func (m *Manager) getPluginID(pluginPath string) string {
manifestPath := filepath.Join(pluginPath, "plugin.json")
data, err := afero.ReadFile(m.fs, manifestPath)
if err != nil {
return ""
}
var manifest struct {
ID string `json:"id"`
}
if err := json.Unmarshal(data, &manifest); err != nil {
return ""
}
return manifest.ID
}
func (m *Manager) GetPluginsDir() string {
return m.pluginsDir
}
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) {
pluginPath := filepath.Join(m.pluginsDir, pluginID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return false, fmt.Errorf("failed to check if plugin exists: %w", err)
}
if !exists {
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", pluginID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return false, fmt.Errorf("failed to check system plugin: %w", err)
}
if systemExists {
return false, nil
}
return false, fmt.Errorf("plugin not installed: %s", pluginID)
}
// Check if there's a .meta file (plugin installed from a monorepo)
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return false, fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
// Plugin is from a monorepo, check the repo directory
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
return m.gitClient.HasUpdates(repoPath)
}
// Plugin is a standalone repo
return m.gitClient.HasUpdates(pluginPath)
}

View File

@@ -0,0 +1,247 @@
package plugins
import (
"os"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
fs := afero.NewMemMapFs()
pluginsDir := "/test-plugins"
manager := &Manager{
fs: fs,
pluginsDir: pluginsDir,
gitClient: &mockGitClient{},
}
return manager, fs, pluginsDir
}
func TestNewManager(t *testing.T) {
manager, err := NewManager()
assert.NoError(t, err)
assert.NotNil(t, manager)
assert.NotEmpty(t, manager.pluginsDir)
}
func TestGetPluginsDir(t *testing.T) {
t.Run("uses XDG_CONFIG_HOME when set", func(t *testing.T) {
oldConfig := os.Getenv("XDG_CONFIG_HOME")
defer func() {
if oldConfig != "" {
os.Setenv("XDG_CONFIG_HOME", oldConfig)
} else {
os.Unsetenv("XDG_CONFIG_HOME")
}
}()
os.Setenv("XDG_CONFIG_HOME", "/tmp/test-config")
dir := getPluginsDir()
assert.Equal(t, "/tmp/test-config/DankMaterialShell/plugins", dir)
})
t.Run("falls back to home directory", func(t *testing.T) {
oldConfig := os.Getenv("XDG_CONFIG_HOME")
defer func() {
if oldConfig != "" {
os.Setenv("XDG_CONFIG_HOME", oldConfig)
} else {
os.Unsetenv("XDG_CONFIG_HOME")
}
}()
os.Unsetenv("XDG_CONFIG_HOME")
dir := getPluginsDir()
assert.Contains(t, dir, ".config/DankMaterialShell/plugins")
})
}
func TestIsInstalled(t *testing.T) {
t.Run("returns true when plugin is installed", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
installed, err := manager.IsInstalled(plugin)
assert.NoError(t, err)
assert.True(t, installed)
})
t.Run("returns false when plugin is not installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
installed, err := manager.IsInstalled(plugin)
assert.NoError(t, err)
assert.False(t, installed)
})
}
func TestInstall(t *testing.T) {
t.Run("installs plugin successfully", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{
ID: "test-plugin",
Name: "TestPlugin",
Repo: "https://github.com/test/plugin",
}
cloneCalled := false
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, filepath.Join(pluginsDir, plugin.ID), path)
assert.Equal(t, plugin.Repo, url)
return fs.MkdirAll(path, 0755)
},
}
manager.gitClient = mockGit
err := manager.Install(plugin)
assert.NoError(t, err)
assert.True(t, cloneCalled)
exists, _ := afero.DirExists(fs, filepath.Join(pluginsDir, plugin.ID))
assert.True(t, exists)
})
t.Run("returns error when plugin already installed", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
err = manager.Install(plugin)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already installed")
})
t.Run("installs monorepo plugin with symlink", func(t *testing.T) {
t.Skip("Skipping symlink test as MemMapFs doesn't support symlinks")
})
}
func TestManagerUpdate(t *testing.T) {
t.Run("updates plugin successfully", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
pullCalled := false
mockGit := &mockGitClient{
pullFunc: func(path string) error {
pullCalled = true
assert.Equal(t, pluginPath, path)
return nil
},
}
manager.gitClient = mockGit
err = manager.Update(plugin)
assert.NoError(t, err)
assert.True(t, pullCalled)
})
t.Run("returns error when plugin not installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
err := manager.Update(plugin)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not installed")
})
}
func TestUninstall(t *testing.T) {
t.Run("uninstalls plugin successfully", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
err = manager.Uninstall(plugin)
assert.NoError(t, err)
exists, _ := afero.DirExists(fs, pluginPath)
assert.False(t, exists)
})
t.Run("returns error when plugin not installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
err := manager.Uninstall(plugin)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not installed")
})
}
func TestListInstalled(t *testing.T) {
t.Run("lists installed plugins", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
err := fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin2"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin2", "plugin.json"), []byte(`{"id":"Plugin2"}`), 0644)
require.NoError(t, err)
installed, err := manager.ListInstalled()
assert.NoError(t, err)
assert.Len(t, installed, 2)
assert.Contains(t, installed, "Plugin1")
assert.Contains(t, installed, "Plugin2")
})
t.Run("returns empty list when no plugins installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
installed, err := manager.ListInstalled()
assert.NoError(t, err)
assert.Empty(t, installed)
})
t.Run("ignores files and .repos directory", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, ".repos"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("test"), 0644)
require.NoError(t, err)
installed, err := manager.ListInstalled()
assert.NoError(t, err)
assert.Len(t, installed, 1)
assert.Equal(t, "Plugin1", installed[0])
})
}
func TestManagerGetPluginsDir(t *testing.T) {
manager, _, pluginsDir := setupTestManager(t)
assert.Equal(t, pluginsDir, manager.GetPluginsDir())
}

View File

@@ -0,0 +1,256 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/go-git/go-git/v6"
"github.com/spf13/afero"
)
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type Plugin struct {
ID string `json:"id"`
Name string `json:"name"`
Capabilities []string `json:"capabilities"`
Category string `json:"category"`
Repo string `json:"repo"`
Path string `json:"path,omitempty"`
Author string `json:"author"`
Description string `json:"description"`
Dependencies []string `json:"dependencies,omitempty"`
Compositors []string `json:"compositors"`
Distro []string `json:"distro"`
Screenshot string `json:"screenshot,omitempty"`
}
type GitClient interface {
PlainClone(path string, url string) error
Pull(path string) error
HasUpdates(path string) (bool, error)
}
type realGitClient struct{}
func (g *realGitClient) PlainClone(path string, url string) error {
_, err := git.PlainClone(path, &git.CloneOptions{
URL: url,
Progress: os.Stdout,
})
return err
}
func (g *realGitClient) Pull(path string) error {
repo, err := git.PlainOpen(path)
if err != nil {
return err
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
err = worktree.Pull(&git.PullOptions{})
if err != nil && err.Error() != "already up-to-date" {
return err
}
return nil
}
func (g *realGitClient) HasUpdates(path string) (bool, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return false, err
}
// Fetch remote changes
err = repo.Fetch(&git.FetchOptions{})
if err != nil && err.Error() != "already up-to-date" {
// If fetch fails, we can't determine if there are updates
// Return false and the error
return false, err
}
// Get the HEAD reference
head, err := repo.Head()
if err != nil {
return false, err
}
// Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
remote, err := repo.Remote("origin")
if err != nil {
return false, err
}
refs, err := remote.List(&git.ListOptions{})
if err != nil {
return false, err
}
// Find the default branch remote ref
var remoteHead string
for _, ref := range refs {
if ref.Name().IsBranch() {
// Try common branch names
if ref.Name().Short() == "main" || ref.Name().Short() == "master" {
remoteHead = ref.Hash().String()
break
}
}
}
// If we couldn't find a remote HEAD, assume no updates
if remoteHead == "" {
return false, nil
}
// Compare local HEAD with remote HEAD
return head.Hash().String() != remoteHead, nil
}
type Registry struct {
fs afero.Fs
cacheDir string
plugins []Plugin
git GitClient
}
func NewRegistry() (*Registry, error) {
return NewRegistryWithFs(afero.NewOsFs())
}
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{
fs: fs,
cacheDir: cacheDir,
git: &realGitClient{},
}, nil
}
func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
}
func (r *Registry) Update() error {
exists, err := afero.DirExists(r.fs, r.cacheDir)
if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err)
}
if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to clone registry: %w", err)
}
} else {
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := r.git.Pull(r.cacheDir); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
}
}
return r.loadPlugins()
}
func (r *Registry) loadPlugins() error {
pluginsDir := filepath.Join(r.cacheDir, "plugins")
entries, err := afero.ReadDir(r.fs, pluginsDir)
if err != nil {
return fmt.Errorf("failed to read plugins directory: %w", err)
}
r.plugins = []Plugin{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
data, err := afero.ReadFile(r.fs, filepath.Join(pluginsDir, entry.Name()))
if err != nil {
continue
}
var plugin Plugin
if err := json.Unmarshal(data, &plugin); err != nil {
continue
}
if plugin.ID == "" {
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
}
r.plugins = append(r.plugins, plugin)
}
return nil
}
func (r *Registry) List() ([]Plugin, error) {
if len(r.plugins) == 0 {
if err := r.Update(); err != nil {
return nil, err
}
}
return SortByFirstParty(r.plugins), nil
}
func (r *Registry) Search(query string) ([]Plugin, error) {
allPlugins, err := r.List()
if err != nil {
return nil, err
}
if query == "" {
return allPlugins, nil
}
return SortByFirstParty(FuzzySearch(query, allPlugins)), nil
}
func (r *Registry) Get(idOrName string) (*Plugin, error) {
plugins, err := r.List()
if err != nil {
return nil, err
}
// First, try to find by ID (preferred method)
for _, p := range plugins {
if p.ID == idOrName {
return &p, nil
}
}
// Fallback to name for backward compatibility
for _, p := range plugins {
if p.Name == idOrName {
return &p, nil
}
}
return nil, fmt.Errorf("plugin not found: %s", idOrName)
}

View File

@@ -0,0 +1,326 @@
package plugins
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockGitClient struct {
cloneFunc func(path string, url string) error
pullFunc func(path string) error
hasUpdatesFunc func(path string) (bool, error)
}
func (m *mockGitClient) PlainClone(path string, url string) error {
if m.cloneFunc != nil {
return m.cloneFunc(path, url)
}
return nil
}
func (m *mockGitClient) Pull(path string) error {
if m.pullFunc != nil {
return m.pullFunc(path)
}
return nil
}
func (m *mockGitClient) HasUpdates(path string) (bool, error) {
if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path)
}
return false, nil
}
func TestNewRegistry(t *testing.T) {
registry, err := NewRegistry()
assert.NoError(t, err)
assert.NotNil(t, registry)
assert.NotEmpty(t, registry.cacheDir)
}
func TestGetCacheDir(t *testing.T) {
cacheDir := getCacheDir()
assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
}
func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
fs := afero.NewMemMapFs()
tmpDir := "/test-cache"
registry := &Registry{
fs: fs,
cacheDir: tmpDir,
plugins: []Plugin{},
git: &mockGitClient{},
}
return registry, fs, tmpDir
}
func createTestPlugin(t *testing.T, fs afero.Fs, dir string, filename string, plugin Plugin) {
pluginsDir := filepath.Join(dir, "plugins")
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
data, err := json.Marshal(plugin)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0644)
require.NoError(t, err)
}
func TestLoadPlugins(t *testing.T) {
t.Run("loads valid plugin files", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin1 := Plugin{
Name: "TestPlugin1",
Capabilities: []string{"dankbar-widget"},
Category: "monitoring",
Repo: "https://github.com/test/plugin1",
Author: "Test Author",
Description: "Test plugin 1",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
plugin2 := Plugin{
Name: "TestPlugin2",
Capabilities: []string{"system-tray"},
Category: "utilities",
Repo: "https://github.com/test/plugin2",
Author: "Another Author",
Description: "Test plugin 2",
Dependencies: []string{"dep1", "dep2"},
Compositors: []string{"hyprland", "niri"},
Distro: []string{"arch"},
Screenshot: "https://example.com/screenshot.png",
}
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
err := registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 2)
assert.Equal(t, "TestPlugin1", registry.plugins[0].Name)
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
})
t.Run("skips non-json files", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("# Test"), 0644)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
})
t.Run("skips directories", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0755)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
})
t.Run("skips invalid json files", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "invalid.json"), []byte("{invalid json}"), 0644)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
})
t.Run("returns error when plugins directory missing", func(t *testing.T) {
registry, _, _ := setupTestRegistry(t)
err := registry.loadPlugins()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read plugins directory")
})
}
func TestList(t *testing.T) {
t.Run("returns cached plugins if available", func(t *testing.T) {
registry, _, _ := setupTestRegistry(t)
plugin := Plugin{
Name: "CachedPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
registry.plugins = []Plugin{plugin}
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "CachedPlugin", plugins[0].Name)
})
t.Run("updates and loads plugins when cache is empty", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
plugin := Plugin{
Name: "NewPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "NewPlugin", plugins[0].Name)
})
}
func TestUpdate(t *testing.T) {
t.Run("clones repository when cache doesn't exist", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{
Name: "RepoPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
cloneCalled := false
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, registryRepo, url)
assert.Equal(t, tmpDir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err := registry.Update()
assert.NoError(t, err)
assert.True(t, cloneCalled)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
})
t.Run("pulls updates when cache exists", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{
Name: "UpdatedPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
err := fs.MkdirAll(tmpDir, 0755)
require.NoError(t, err)
pullCalled := false
mockGit := &mockGitClient{
pullFunc: func(path string) error {
pullCalled = true
assert.Equal(t, tmpDir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err = registry.Update()
assert.NoError(t, err)
assert.True(t, pullCalled)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
})
}

105
internal/plugins/search.go Normal file
View File

@@ -0,0 +1,105 @@
package plugins
import (
"sort"
"strings"
)
func FuzzySearch(query string, plugins []Plugin) []Plugin {
if query == "" {
return plugins
}
queryLower := strings.ToLower(query)
var results []Plugin
for _, plugin := range plugins {
if fuzzyMatch(queryLower, strings.ToLower(plugin.Name)) ||
fuzzyMatch(queryLower, strings.ToLower(plugin.Category)) ||
fuzzyMatch(queryLower, strings.ToLower(plugin.Description)) ||
fuzzyMatch(queryLower, strings.ToLower(plugin.Author)) {
results = append(results, plugin)
}
}
return results
}
func fuzzyMatch(query, text string) bool {
queryIdx := 0
for _, char := range text {
if queryIdx < len(query) && char == rune(query[queryIdx]) {
queryIdx++
}
}
return queryIdx == len(query)
}
func FilterByCategory(category string, plugins []Plugin) []Plugin {
if category == "" {
return plugins
}
var results []Plugin
categoryLower := strings.ToLower(category)
for _, plugin := range plugins {
if strings.ToLower(plugin.Category) == categoryLower {
results = append(results, plugin)
}
}
return results
}
func FilterByCompositor(compositor string, plugins []Plugin) []Plugin {
if compositor == "" {
return plugins
}
var results []Plugin
compositorLower := strings.ToLower(compositor)
for _, plugin := range plugins {
for _, comp := range plugin.Compositors {
if strings.ToLower(comp) == compositorLower {
results = append(results, plugin)
break
}
}
}
return results
}
func FilterByCapability(capability string, plugins []Plugin) []Plugin {
if capability == "" {
return plugins
}
var results []Plugin
capabilityLower := strings.ToLower(capability)
for _, plugin := range plugins {
for _, cap := range plugin.Capabilities {
if strings.ToLower(cap) == capabilityLower {
results = append(results, plugin)
break
}
}
}
return results
}
func SortByFirstParty(plugins []Plugin) []Plugin {
sort.SliceStable(plugins, func(i, j int) bool {
isFirstPartyI := strings.HasPrefix(plugins[i].Repo, "https://github.com/AvengeMedia")
isFirstPartyJ := strings.HasPrefix(plugins[j].Repo, "https://github.com/AvengeMedia")
if isFirstPartyI != isFirstPartyJ {
return isFirstPartyI
}
return false
})
return plugins
}

View File

@@ -0,0 +1,491 @@
// Generated by go-wayland-scanner
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
// XML file : internal/proto/xml/dwl-ipc-unstable-v2.xml
//
// dwl_ipc_unstable_v2 Protocol Copyright:
package dwl_ipc
import "github.com/yaslama/go-wayland/wayland/client"
// ZdwlIpcManagerV2InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZdwlIpcManagerV2InterfaceName = "zdwl_ipc_manager_v2"
// ZdwlIpcManagerV2 : manage dwl state
//
// This interface is exposed as a global in wl_registry.
//
// Clients can use this interface to get a dwl_ipc_output.
// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
type ZdwlIpcManagerV2 struct {
client.BaseProxy
tagsHandler ZdwlIpcManagerV2TagsHandlerFunc
layoutHandler ZdwlIpcManagerV2LayoutHandlerFunc
}
// NewZdwlIpcManagerV2 : manage dwl state
//
// This interface is exposed as a global in wl_registry.
//
// Clients can use this interface to get a dwl_ipc_output.
// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
func NewZdwlIpcManagerV2(ctx *client.Context) *ZdwlIpcManagerV2 {
zdwlIpcManagerV2 := &ZdwlIpcManagerV2{}
ctx.Register(zdwlIpcManagerV2)
return zdwlIpcManagerV2
}
// Release : release dwl_ipc_manager
//
// Indicates that the client will not the dwl_ipc_manager object anymore.
// Objects created through this instance are not affected.
func (i *ZdwlIpcManagerV2) Release() error {
defer i.Context().Unregister(i)
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// GetOutput : get a dwl_ipc_outout for a wl_output
//
// Get a dwl_ipc_outout for the specified wl_output.
func (i *ZdwlIpcManagerV2) GetOutput(output *client.Output) (*ZdwlIpcOutputV2, error) {
id := NewZdwlIpcOutputV2(i.Context())
const opcode = 1
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], id.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], output.ID())
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return id, err
}
// ZdwlIpcManagerV2TagsEvent : Announces tag amount
//
// This event is sent after binding.
// A roundtrip after binding guarantees the client recieved all tags.
type ZdwlIpcManagerV2TagsEvent struct {
Amount uint32
}
type ZdwlIpcManagerV2TagsHandlerFunc func(ZdwlIpcManagerV2TagsEvent)
// SetTagsHandler : sets handler for ZdwlIpcManagerV2TagsEvent
func (i *ZdwlIpcManagerV2) SetTagsHandler(f ZdwlIpcManagerV2TagsHandlerFunc) {
i.tagsHandler = f
}
// ZdwlIpcManagerV2LayoutEvent : Announces a layout
//
// This event is sent after binding.
// A roundtrip after binding guarantees the client recieved all layouts.
type ZdwlIpcManagerV2LayoutEvent struct {
Name string
}
type ZdwlIpcManagerV2LayoutHandlerFunc func(ZdwlIpcManagerV2LayoutEvent)
// SetLayoutHandler : sets handler for ZdwlIpcManagerV2LayoutEvent
func (i *ZdwlIpcManagerV2) SetLayoutHandler(f ZdwlIpcManagerV2LayoutHandlerFunc) {
i.layoutHandler = f
}
func (i *ZdwlIpcManagerV2) Dispatch(opcode uint32, fd int, data []byte) {
switch opcode {
case 0:
if i.tagsHandler == nil {
return
}
var e ZdwlIpcManagerV2TagsEvent
l := 0
e.Amount = client.Uint32(data[l : l+4])
l += 4
i.tagsHandler(e)
case 1:
if i.layoutHandler == nil {
return
}
var e ZdwlIpcManagerV2LayoutEvent
l := 0
nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Name = client.String(data[l : l+nameLen])
l += nameLen
i.layoutHandler(e)
}
}
// ZdwlIpcOutputV2InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZdwlIpcOutputV2InterfaceName = "zdwl_ipc_output_v2"
// ZdwlIpcOutputV2 : control dwl output
//
// Observe and control a dwl output.
//
// Events are double-buffered:
// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
//
// Request are not double-buffered:
// The compositor will update immediately upon request.
type ZdwlIpcOutputV2 struct {
client.BaseProxy
toggleVisibilityHandler ZdwlIpcOutputV2ToggleVisibilityHandlerFunc
activeHandler ZdwlIpcOutputV2ActiveHandlerFunc
tagHandler ZdwlIpcOutputV2TagHandlerFunc
layoutHandler ZdwlIpcOutputV2LayoutHandlerFunc
titleHandler ZdwlIpcOutputV2TitleHandlerFunc
appidHandler ZdwlIpcOutputV2AppidHandlerFunc
layoutSymbolHandler ZdwlIpcOutputV2LayoutSymbolHandlerFunc
frameHandler ZdwlIpcOutputV2FrameHandlerFunc
}
// NewZdwlIpcOutputV2 : control dwl output
//
// Observe and control a dwl output.
//
// Events are double-buffered:
// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
//
// Request are not double-buffered:
// The compositor will update immediately upon request.
func NewZdwlIpcOutputV2(ctx *client.Context) *ZdwlIpcOutputV2 {
zdwlIpcOutputV2 := &ZdwlIpcOutputV2{}
ctx.Register(zdwlIpcOutputV2)
return zdwlIpcOutputV2
}
// Release : release dwl_ipc_outout
//
// Indicates to that the client no longer needs this dwl_ipc_output.
func (i *ZdwlIpcOutputV2) Release() error {
defer i.Context().Unregister(i)
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// SetTags : Set the active tags of this output
//
// tagmask: bitmask of the tags that should be set.
// toggleTagset: toggle the selected tagset, zero for invalid, nonzero for valid.
func (i *ZdwlIpcOutputV2) SetTags(tagmask, toggleTagset uint32) error {
const opcode = 1
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(tagmask))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(toggleTagset))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// SetClientTags : Set the tags of the focused client.
//
// The tags are updated as follows:
// new_tags = (current_tags AND and_tags) XOR xor_tags
func (i *ZdwlIpcOutputV2) SetClientTags(andTags, xorTags uint32) error {
const opcode = 2
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(andTags))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(xorTags))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// SetLayout : Set the layout of this output
//
// index: index of a layout recieved by dwl_ipc_manager.layout
func (i *ZdwlIpcOutputV2) SetLayout(index uint32) error {
const opcode = 3
const _reqBufLen = 8 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(index))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
type ZdwlIpcOutputV2TagState uint32
// ZdwlIpcOutputV2TagState :
const (
// ZdwlIpcOutputV2TagStateNone : no state
ZdwlIpcOutputV2TagStateNone ZdwlIpcOutputV2TagState = 0
// ZdwlIpcOutputV2TagStateActive : tag is active
ZdwlIpcOutputV2TagStateActive ZdwlIpcOutputV2TagState = 1
// ZdwlIpcOutputV2TagStateUrgent : tag has at least one urgent client
ZdwlIpcOutputV2TagStateUrgent ZdwlIpcOutputV2TagState = 2
)
func (e ZdwlIpcOutputV2TagState) Name() string {
switch e {
case ZdwlIpcOutputV2TagStateNone:
return "none"
case ZdwlIpcOutputV2TagStateActive:
return "active"
case ZdwlIpcOutputV2TagStateUrgent:
return "urgent"
default:
return ""
}
}
func (e ZdwlIpcOutputV2TagState) Value() string {
switch e {
case ZdwlIpcOutputV2TagStateNone:
return "0"
case ZdwlIpcOutputV2TagStateActive:
return "1"
case ZdwlIpcOutputV2TagStateUrgent:
return "2"
default:
return ""
}
}
func (e ZdwlIpcOutputV2TagState) String() string {
return e.Name() + "=" + e.Value()
}
// ZdwlIpcOutputV2ToggleVisibilityEvent : Toggle client visibilty
//
// Indicates the client should hide or show themselves.
// If the client is visible then hide, if hidden then show.
type ZdwlIpcOutputV2ToggleVisibilityEvent struct{}
type ZdwlIpcOutputV2ToggleVisibilityHandlerFunc func(ZdwlIpcOutputV2ToggleVisibilityEvent)
// SetToggleVisibilityHandler : sets handler for ZdwlIpcOutputV2ToggleVisibilityEvent
func (i *ZdwlIpcOutputV2) SetToggleVisibilityHandler(f ZdwlIpcOutputV2ToggleVisibilityHandlerFunc) {
i.toggleVisibilityHandler = f
}
// ZdwlIpcOutputV2ActiveEvent : Update the selected output.
//
// Indicates if the output is active. Zero is invalid, nonzero is valid.
type ZdwlIpcOutputV2ActiveEvent struct {
Active uint32
}
type ZdwlIpcOutputV2ActiveHandlerFunc func(ZdwlIpcOutputV2ActiveEvent)
// SetActiveHandler : sets handler for ZdwlIpcOutputV2ActiveEvent
func (i *ZdwlIpcOutputV2) SetActiveHandler(f ZdwlIpcOutputV2ActiveHandlerFunc) {
i.activeHandler = f
}
// ZdwlIpcOutputV2TagEvent : Update the state of a tag.
//
// Indicates that a tag has been updated.
type ZdwlIpcOutputV2TagEvent struct {
Tag uint32
State uint32
Clients uint32
Focused uint32
}
type ZdwlIpcOutputV2TagHandlerFunc func(ZdwlIpcOutputV2TagEvent)
// SetTagHandler : sets handler for ZdwlIpcOutputV2TagEvent
func (i *ZdwlIpcOutputV2) SetTagHandler(f ZdwlIpcOutputV2TagHandlerFunc) {
i.tagHandler = f
}
// ZdwlIpcOutputV2LayoutEvent : Update the layout.
//
// Indicates a new layout is selected.
type ZdwlIpcOutputV2LayoutEvent struct {
Layout uint32
}
type ZdwlIpcOutputV2LayoutHandlerFunc func(ZdwlIpcOutputV2LayoutEvent)
// SetLayoutHandler : sets handler for ZdwlIpcOutputV2LayoutEvent
func (i *ZdwlIpcOutputV2) SetLayoutHandler(f ZdwlIpcOutputV2LayoutHandlerFunc) {
i.layoutHandler = f
}
// ZdwlIpcOutputV2TitleEvent : Update the title.
//
// Indicates the title has changed.
type ZdwlIpcOutputV2TitleEvent struct {
Title string
}
type ZdwlIpcOutputV2TitleHandlerFunc func(ZdwlIpcOutputV2TitleEvent)
// SetTitleHandler : sets handler for ZdwlIpcOutputV2TitleEvent
func (i *ZdwlIpcOutputV2) SetTitleHandler(f ZdwlIpcOutputV2TitleHandlerFunc) {
i.titleHandler = f
}
// ZdwlIpcOutputV2AppidEvent : Update the appid.
//
// Indicates the appid has changed.
type ZdwlIpcOutputV2AppidEvent struct {
Appid string
}
type ZdwlIpcOutputV2AppidHandlerFunc func(ZdwlIpcOutputV2AppidEvent)
// SetAppidHandler : sets handler for ZdwlIpcOutputV2AppidEvent
func (i *ZdwlIpcOutputV2) SetAppidHandler(f ZdwlIpcOutputV2AppidHandlerFunc) {
i.appidHandler = f
}
// ZdwlIpcOutputV2LayoutSymbolEvent : Update the current layout symbol
//
// Indicates the layout has changed. Since layout symbols are dynamic.
// As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying.
// You can ignore the zdwl_ipc_output.layout event.
type ZdwlIpcOutputV2LayoutSymbolEvent struct {
Layout string
}
type ZdwlIpcOutputV2LayoutSymbolHandlerFunc func(ZdwlIpcOutputV2LayoutSymbolEvent)
// SetLayoutSymbolHandler : sets handler for ZdwlIpcOutputV2LayoutSymbolEvent
func (i *ZdwlIpcOutputV2) SetLayoutSymbolHandler(f ZdwlIpcOutputV2LayoutSymbolHandlerFunc) {
i.layoutSymbolHandler = f
}
// ZdwlIpcOutputV2FrameEvent : The update sequence is done.
//
// Indicates that a sequence of status updates have finished and the client should redraw.
type ZdwlIpcOutputV2FrameEvent struct{}
type ZdwlIpcOutputV2FrameHandlerFunc func(ZdwlIpcOutputV2FrameEvent)
// SetFrameHandler : sets handler for ZdwlIpcOutputV2FrameEvent
func (i *ZdwlIpcOutputV2) SetFrameHandler(f ZdwlIpcOutputV2FrameHandlerFunc) {
i.frameHandler = f
}
func (i *ZdwlIpcOutputV2) Dispatch(opcode uint32, fd int, data []byte) {
switch opcode {
case 0:
if i.toggleVisibilityHandler == nil {
return
}
var e ZdwlIpcOutputV2ToggleVisibilityEvent
i.toggleVisibilityHandler(e)
case 1:
if i.activeHandler == nil {
return
}
var e ZdwlIpcOutputV2ActiveEvent
l := 0
e.Active = client.Uint32(data[l : l+4])
l += 4
i.activeHandler(e)
case 2:
if i.tagHandler == nil {
return
}
var e ZdwlIpcOutputV2TagEvent
l := 0
e.Tag = client.Uint32(data[l : l+4])
l += 4
e.State = client.Uint32(data[l : l+4])
l += 4
e.Clients = client.Uint32(data[l : l+4])
l += 4
e.Focused = client.Uint32(data[l : l+4])
l += 4
i.tagHandler(e)
case 3:
if i.layoutHandler == nil {
return
}
var e ZdwlIpcOutputV2LayoutEvent
l := 0
e.Layout = client.Uint32(data[l : l+4])
l += 4
i.layoutHandler(e)
case 4:
if i.titleHandler == nil {
return
}
var e ZdwlIpcOutputV2TitleEvent
l := 0
titleLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Title = client.String(data[l : l+titleLen])
l += titleLen
i.titleHandler(e)
case 5:
if i.appidHandler == nil {
return
}
var e ZdwlIpcOutputV2AppidEvent
l := 0
appidLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Appid = client.String(data[l : l+appidLen])
l += appidLen
i.appidHandler(e)
case 6:
if i.layoutSymbolHandler == nil {
return
}
var e ZdwlIpcOutputV2LayoutSymbolEvent
l := 0
layoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Layout = client.String(data[l : l+layoutLen])
l += layoutLen
i.layoutSymbolHandler(e)
case 7:
if i.frameHandler == nil {
return
}
var e ZdwlIpcOutputV2FrameEvent
i.frameHandler(e)
}
}

View File

@@ -0,0 +1,268 @@
// Generated by go-wayland-scanner
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
// XML file : wayland-protocols/wlr-gamma-control-unstable-v1.xml
//
// wlr_gamma_control_unstable_v1 Protocol Copyright:
//
// Copyright © 2015 Giulio camuffo
// Copyright © 2018 Simon Ser
//
// Permission to use, copy, modify, distribute, and sell this
// software and its documentation for any purpose is hereby granted
// without fee, provided that the above copyright notice appear in
// all copies and that both that copyright notice and this permission
// notice appear in supporting documentation, and that the name of
// the copyright holders not be used in advertising or publicity
// pertaining to distribution of the software without specific,
// written prior permission. The copyright holders make no
// representations about the suitability of this software for any
// purpose. It is provided "as is" without express or implied
// warranty.
//
// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE.
package wlr_gamma_control
import (
"github.com/yaslama/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
// ZwlrGammaControlManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZwlrGammaControlManagerV1InterfaceName = "zwlr_gamma_control_manager_v1"
// ZwlrGammaControlManagerV1 : manager to create per-output gamma controls
//
// This interface is a manager that allows creating per-output gamma
// controls.
type ZwlrGammaControlManagerV1 struct {
client.BaseProxy
}
// NewZwlrGammaControlManagerV1 : manager to create per-output gamma controls
//
// This interface is a manager that allows creating per-output gamma
// controls.
func NewZwlrGammaControlManagerV1(ctx *client.Context) *ZwlrGammaControlManagerV1 {
zwlrGammaControlManagerV1 := &ZwlrGammaControlManagerV1{}
ctx.Register(zwlrGammaControlManagerV1)
return zwlrGammaControlManagerV1
}
// GetGammaControl : get a gamma control for an output
//
// Create a gamma control that can be used to adjust gamma tables for the
// provided output.
func (i *ZwlrGammaControlManagerV1) GetGammaControl(output *client.Output) (*ZwlrGammaControlV1, error) {
id := NewZwlrGammaControlV1(i.Context())
const opcode = 0
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], id.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], output.ID())
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return id, err
}
// Destroy : destroy the manager
//
// All objects created by the manager will still remain valid, until their
// appropriate destroy request has been called.
func (i *ZwlrGammaControlManagerV1) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// ZwlrGammaControlV1InterfaceName is the name of the interface as it appears in the [client.Registry].
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
const ZwlrGammaControlV1InterfaceName = "zwlr_gamma_control_v1"
// ZwlrGammaControlV1 : adjust gamma tables for an output
//
// This interface allows a client to adjust gamma tables for a particular
// output.
//
// The client will receive the gamma size, and will then be able to set gamma
// tables. At any time the compositor can send a failed event indicating that
// this object is no longer valid.
//
// There can only be at most one gamma control object per output, which
// has exclusive access to this particular output. When the gamma control
// object is destroyed, the gamma table is restored to its original value.
type ZwlrGammaControlV1 struct {
client.BaseProxy
gammaSizeHandler ZwlrGammaControlV1GammaSizeHandlerFunc
failedHandler ZwlrGammaControlV1FailedHandlerFunc
}
// NewZwlrGammaControlV1 : adjust gamma tables for an output
//
// This interface allows a client to adjust gamma tables for a particular
// output.
//
// The client will receive the gamma size, and will then be able to set gamma
// tables. At any time the compositor can send a failed event indicating that
// this object is no longer valid.
//
// There can only be at most one gamma control object per output, which
// has exclusive access to this particular output. When the gamma control
// object is destroyed, the gamma table is restored to its original value.
func NewZwlrGammaControlV1(ctx *client.Context) *ZwlrGammaControlV1 {
zwlrGammaControlV1 := &ZwlrGammaControlV1{}
ctx.Register(zwlrGammaControlV1)
return zwlrGammaControlV1
}
// SetGamma : set the gamma table
//
// Set the gamma table. The file descriptor can be memory-mapped to provide
// the raw gamma table, which contains successive gamma ramps for the red,
// green and blue channels. Each gamma ramp is an array of 16-byte unsigned
// integers which has the same length as the gamma size.
//
// The file descriptor data must have the same length as three times the
// gamma size.
//
// fd: gamma table file descriptor
func (i *ZwlrGammaControlV1) SetGamma(fd int) error {
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
oob := unix.UnixRights(int(fd))
err := i.Context().WriteMsg(_reqBuf[:], oob)
return err
}
// Destroy : destroy this control
//
// Destroys the gamma control object. If the object is still valid, this
// restores the original gamma tables.
func (i *ZwlrGammaControlV1) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
type ZwlrGammaControlV1Error uint32
// ZwlrGammaControlV1Error :
const (
// ZwlrGammaControlV1ErrorInvalidGamma : invalid gamma tables
ZwlrGammaControlV1ErrorInvalidGamma ZwlrGammaControlV1Error = 1
)
func (e ZwlrGammaControlV1Error) Name() string {
switch e {
case ZwlrGammaControlV1ErrorInvalidGamma:
return "invalid_gamma"
default:
return ""
}
}
func (e ZwlrGammaControlV1Error) Value() string {
switch e {
case ZwlrGammaControlV1ErrorInvalidGamma:
return "1"
default:
return ""
}
}
func (e ZwlrGammaControlV1Error) String() string {
return e.Name() + "=" + e.Value()
}
// ZwlrGammaControlV1GammaSizeEvent : size of gamma ramps
//
// Advertise the size of each gamma ramp.
//
// This event is sent immediately when the gamma control object is created.
type ZwlrGammaControlV1GammaSizeEvent struct {
Size uint32
}
type ZwlrGammaControlV1GammaSizeHandlerFunc func(ZwlrGammaControlV1GammaSizeEvent)
// SetGammaSizeHandler : sets handler for ZwlrGammaControlV1GammaSizeEvent
func (i *ZwlrGammaControlV1) SetGammaSizeHandler(f ZwlrGammaControlV1GammaSizeHandlerFunc) {
i.gammaSizeHandler = f
}
// ZwlrGammaControlV1FailedEvent : object no longer valid
//
// This event indicates that the gamma control is no longer valid. This
// can happen for a number of reasons, including:
// - The output doesn't support gamma tables
// - Setting the gamma tables failed
// - Another client already has exclusive gamma control for this output
// - The compositor has transferred gamma control to another client
//
// Upon receiving this event, the client should destroy this object.
type ZwlrGammaControlV1FailedEvent struct{}
type ZwlrGammaControlV1FailedHandlerFunc func(ZwlrGammaControlV1FailedEvent)
// SetFailedHandler : sets handler for ZwlrGammaControlV1FailedEvent
func (i *ZwlrGammaControlV1) SetFailedHandler(f ZwlrGammaControlV1FailedHandlerFunc) {
i.failedHandler = f
}
func (i *ZwlrGammaControlV1) Dispatch(opcode uint32, fd int, data []byte) {
switch opcode {
case 0:
if i.gammaSizeHandler == nil {
return
}
var e ZwlrGammaControlV1GammaSizeEvent
l := 0
e.Size = client.Uint32(data[l : l+4])
l += 4
i.gammaSizeHandler(e)
case 1:
if i.failedHandler == nil {
return
}
var e ZwlrGammaControlV1FailedEvent
i.failedHandler(e)
}
}

View File

@@ -0,0 +1,166 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
This is largely ripped from somebar's ipc patchset; just with some personal modifications.
I would probably just submit raphi's patchset but I don't think that would be polite.
-->
<protocol name="dwl_ipc_unstable_v2">
<description summary="inter-proccess-communication about dwl's state">
This protocol allows clients to update and get updates from dwl.
Warning! The protocol described in this file is experimental and
backward incompatible changes may be made. Backward compatible
changes may be added together with the corresponding interface
version bump.
Backward incompatible changes are done by bumping the version
number in the protocol and interface names and resetting the
interface version. Once the protocol is to be declared stable,
the 'z' prefix and the version number in the protocol and
interface names are removed and the interface version number is
reset.
</description>
<interface name="zdwl_ipc_manager_v2" version="1">
<description summary="manage dwl state">
This interface is exposed as a global in wl_registry.
Clients can use this interface to get a dwl_ipc_output.
After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
</description>
<request name="release" type="destructor">
<description summary="release dwl_ipc_manager">
Indicates that the client will not the dwl_ipc_manager object anymore.
Objects created through this instance are not affected.
</description>
</request>
<request name="get_output">
<description summary="get a dwl_ipc_outout for a wl_output">
Get a dwl_ipc_outout for the specified wl_output.
</description>
<arg name="id" type="new_id" interface="zdwl_ipc_output_v2"/>
<arg name="output" type="object" interface="wl_output"/>
</request>
<event name="tags">
<description summary="Announces tag amount">
This event is sent after binding.
A roundtrip after binding guarantees the client recieved all tags.
</description>
<arg name="amount" type="uint"/>
</event>
<event name="layout">
<description summary="Announces a layout">
This event is sent after binding.
A roundtrip after binding guarantees the client recieved all layouts.
</description>
<arg name="name" type="string"/>
</event>
</interface>
<interface name="zdwl_ipc_output_v2" version="1">
<description summary="control dwl output">
Observe and control a dwl output.
Events are double-buffered:
Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
Request are not double-buffered:
The compositor will update immediately upon request.
</description>
<enum name="tag_state">
<entry name="none" value="0" summary="no state"/>
<entry name="active" value="1" summary="tag is active"/>
<entry name="urgent" value="2" summary="tag has at least one urgent client"/>
</enum>
<request name="release" type="destructor">
<description summary="release dwl_ipc_outout">
Indicates to that the client no longer needs this dwl_ipc_output.
</description>
</request>
<event name="toggle_visibility">
<description summary="Toggle client visibilty">
Indicates the client should hide or show themselves.
If the client is visible then hide, if hidden then show.
</description>
</event>
<event name="active">
<description summary="Update the selected output.">
Indicates if the output is active. Zero is invalid, nonzero is valid.
</description>
<arg name="active" type="uint"/>
</event>
<event name="tag">
<description summary="Update the state of a tag.">
Indicates that a tag has been updated.
</description>
<arg name="tag" type="uint" summary="Index of the tag"/>
<arg name="state" type="uint" enum="tag_state" summary="The state of the tag."/>
<arg name="clients" type="uint" summary="The number of clients in the tag."/>
<arg name="focused" type="uint" summary="If there is a focused client. Nonzero being valid, zero being invalid."/>
</event>
<event name="layout">
<description summary="Update the layout.">
Indicates a new layout is selected.
</description>
<arg name="layout" type="uint" summary="Index of the layout."/>
</event>
<event name="title">
<description summary="Update the title.">
Indicates the title has changed.
</description>
<arg name="title" type="string" summary="The new title name."/>
</event>
<event name="appid" since="1">
<description summary="Update the appid.">
Indicates the appid has changed.
</description>
<arg name="appid" type="string" summary="The new appid."/>
</event>
<event name="layout_symbol" since="1">
<description summary="Update the current layout symbol">
Indicates the layout has changed. Since layout symbols are dynamic.
As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying.
You can ignore the zdwl_ipc_output.layout event.
</description>
<arg name="layout" type="string" summary="The new layout"/>
</event>
<event name="frame">
<description summary="The update sequence is done.">
Indicates that a sequence of status updates have finished and the client should redraw.
</description>
</event>
<request name="set_tags">
<description summary="Set the active tags of this output"/>
<arg name="tagmask" type="uint" summary="bitmask of the tags that should be set."/>
<arg name="toggle_tagset" type="uint" summary="toggle the selected tagset, zero for invalid, nonzero for valid."/>
</request>
<request name="set_client_tags">
<description summary="Set the tags of the focused client.">
The tags are updated as follows:
new_tags = (current_tags AND and_tags) XOR xor_tags
</description>
<arg name="and_tags" type="uint"/>
<arg name="xor_tags" type="uint"/>
</request>
<request name="set_layout">
<description summary="Set the layout of this output"/>
<arg name="index" type="uint" summary="index of a layout recieved by dwl_ipc_manager.layout"/>
</request>
</interface>
</protocol>

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="wlr_gamma_control_unstable_v1">
<copyright>
Copyright © 2015 Giulio camuffo
Copyright © 2018 Simon Ser
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
</copyright>
<description summary="manage gamma tables of outputs">
This protocol allows a privileged client to set the gamma tables for
outputs.
Warning! The protocol described in this file is experimental and
backward incompatible changes may be made. Backward compatible changes
may be added together with the corresponding interface version bump.
Backward incompatible changes are done by bumping the version number in
the protocol and interface names and resetting the interface version.
Once the protocol is to be declared stable, the 'z' prefix and the
version number in the protocol and interface names are removed and the
interface version number is reset.
</description>
<interface name="zwlr_gamma_control_manager_v1" version="1">
<description summary="manager to create per-output gamma controls">
This interface is a manager that allows creating per-output gamma
controls.
</description>
<request name="get_gamma_control">
<description summary="get a gamma control for an output">
Create a gamma control that can be used to adjust gamma tables for the
provided output.
</description>
<arg name="id" type="new_id" interface="zwlr_gamma_control_v1"/>
<arg name="output" type="object" interface="wl_output"/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the manager">
All objects created by the manager will still remain valid, until their
appropriate destroy request has been called.
</description>
</request>
</interface>
<interface name="zwlr_gamma_control_v1" version="1">
<description summary="adjust gamma tables for an output">
This interface allows a client to adjust gamma tables for a particular
output.
The client will receive the gamma size, and will then be able to set gamma
tables. At any time the compositor can send a failed event indicating that
this object is no longer valid.
There can only be at most one gamma control object per output, which
has exclusive access to this particular output. When the gamma control
object is destroyed, the gamma table is restored to its original value.
</description>
<event name="gamma_size">
<description summary="size of gamma ramps">
Advertise the size of each gamma ramp.
This event is sent immediately when the gamma control object is created.
</description>
<arg name="size" type="uint" summary="number of elements in a ramp"/>
</event>
<enum name="error">
<entry name="invalid_gamma" value="1" summary="invalid gamma tables"/>
</enum>
<request name="set_gamma">
<description summary="set the gamma table">
Set the gamma table. The file descriptor can be memory-mapped to provide
the raw gamma table, which contains successive gamma ramps for the red,
green and blue channels. Each gamma ramp is an array of 16-byte unsigned
integers which has the same length as the gamma size.
The file descriptor data must have the same length as three times the
gamma size.
</description>
<arg name="fd" type="fd" summary="gamma table file descriptor"/>
</request>
<event name="failed">
<description summary="object no longer valid">
This event indicates that the gamma control is no longer valid. This
can happen for a number of reasons, including:
- The output doesn't support gamma tables
- Setting the gamma tables failed
- Another client already has exclusive gamma control for this output
- The compositor has transferred gamma control to another client
Upon receiving this event, the client should destroy this object.
</description>
</event>
<request name="destroy" type="destructor">
<description summary="destroy this control">
Destroys the gamma control object. If the object is still valid, this
restores the original gamma tables.
</description>
</request>
</interface>
</protocol>

View File

@@ -0,0 +1,341 @@
package bluez
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/AvengeMedia/danklinux/internal/errdefs"
"github.com/AvengeMedia/danklinux/internal/log"
"github.com/godbus/dbus/v5"
)
const (
bluezService = "org.bluez"
agentManagerPath = "/org/bluez"
agentManagerIface = "org.bluez.AgentManager1"
agent1Iface = "org.bluez.Agent1"
device1Iface = "org.bluez.Device1"
agentPath = "/com/danklinux/bluez/agent"
agentCapability = "KeyboardDisplay"
)
const introspectXML = `
<node>
<interface name="org.bluez.Agent1">
<method name="Release"/>
<method name="RequestPinCode">
<arg direction="in" type="o" name="device"/>
<arg direction="out" type="s" name="pincode"/>
</method>
<method name="RequestPasskey">
<arg direction="in" type="o" name="device"/>
<arg direction="out" type="u" name="passkey"/>
</method>
<method name="DisplayPinCode">
<arg direction="in" type="o" name="device"/>
<arg direction="in" type="s" name="pincode"/>
</method>
<method name="DisplayPasskey">
<arg direction="in" type="o" name="device"/>
<arg direction="in" type="u" name="passkey"/>
<arg direction="in" type="q" name="entered"/>
</method>
<method name="RequestConfirmation">
<arg direction="in" type="o" name="device"/>
<arg direction="in" type="u" name="passkey"/>
</method>
<method name="RequestAuthorization">
<arg direction="in" type="o" name="device"/>
</method>
<method name="AuthorizeService">
<arg direction="in" type="o" name="device"/>
<arg direction="in" type="s" name="uuid"/>
</method>
<method name="Cancel"/>
</interface>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
<arg direction="out" type="s" name="data"/>
</method>
</interface>
</node>`
type BluezAgent struct {
conn *dbus.Conn
broker PromptBroker
}
func NewBluezAgent(broker PromptBroker) (*BluezAgent, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("system bus connection failed: %w", err)
}
agent := &BluezAgent{
conn: conn,
broker: broker,
}
if err := conn.Export(agent, dbus.ObjectPath(agentPath), agent1Iface); err != nil {
conn.Close()
return nil, fmt.Errorf("agent export failed: %w", err)
}
if err := conn.Export(agent, dbus.ObjectPath(agentPath), "org.freedesktop.DBus.Introspectable"); err != nil {
conn.Close()
return nil, fmt.Errorf("introspection export failed: %w", err)
}
mgr := conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
if err := mgr.Call(agentManagerIface+".RegisterAgent", 0, dbus.ObjectPath(agentPath), agentCapability).Err; err != nil {
conn.Close()
return nil, fmt.Errorf("agent registration failed: %w", err)
}
if err := mgr.Call(agentManagerIface+".RequestDefaultAgent", 0, dbus.ObjectPath(agentPath)).Err; err != nil {
log.Debugf("[BluezAgent] not default agent: %v", err)
}
log.Infof("[BluezAgent] registered at %s with capability %s", agentPath, agentCapability)
return agent, nil
}
func (a *BluezAgent) Close() {
if a.conn == nil {
return
}
mgr := a.conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
mgr.Call(agentManagerIface+".UnregisterAgent", 0, dbus.ObjectPath(agentPath))
a.conn.Close()
}
func (a *BluezAgent) Release() *dbus.Error {
log.Infof("[BluezAgent] Release called")
return nil
}
func (a *BluezAgent) RequestPinCode(device dbus.ObjectPath) (string, *dbus.Error) {
log.Infof("[BluezAgent] RequestPinCode: device=%s", device)
secrets, err := a.promptFor(device, "pin", []string{"pin"}, nil)
if err != nil {
log.Warnf("[BluezAgent] RequestPinCode failed: %v", err)
return "", a.errorFrom(err)
}
pin := secrets["pin"]
log.Infof("[BluezAgent] RequestPinCode returning PIN (len=%d)", len(pin))
return pin, nil
}
func (a *BluezAgent) RequestPasskey(device dbus.ObjectPath) (uint32, *dbus.Error) {
log.Infof("[BluezAgent] RequestPasskey: device=%s", device)
secrets, err := a.promptFor(device, "passkey", []string{"passkey"}, nil)
if err != nil {
log.Warnf("[BluezAgent] RequestPasskey failed: %v", err)
return 0, a.errorFrom(err)
}
passkey, err := strconv.ParseUint(secrets["passkey"], 10, 32)
if err != nil {
log.Warnf("[BluezAgent] invalid passkey format: %v", err)
return 0, dbus.MakeFailedError(fmt.Errorf("invalid passkey: %w", err))
}
log.Infof("[BluezAgent] RequestPasskey returning: %d", passkey)
return uint32(passkey), nil
}
func (a *BluezAgent) DisplayPinCode(device dbus.ObjectPath, pincode string) *dbus.Error {
log.Infof("[BluezAgent] DisplayPinCode: device=%s, pin=%s", device, pincode)
_, err := a.promptFor(device, "display-pin", []string{}, &pincode)
if err != nil {
log.Warnf("[BluezAgent] DisplayPinCode acknowledgment failed: %v", err)
}
return nil
}
func (a *BluezAgent) DisplayPasskey(device dbus.ObjectPath, passkey uint32, entered uint16) *dbus.Error {
log.Infof("[BluezAgent] DisplayPasskey: device=%s, passkey=%06d, entered=%d", device, passkey, entered)
if entered == 0 {
pk := passkey
_, err := a.promptFor(device, "display-passkey", []string{}, nil)
if err != nil {
log.Warnf("[BluezAgent] DisplayPasskey acknowledgment failed: %v", err)
}
_ = pk
}
return nil
}
func (a *BluezAgent) RequestConfirmation(device dbus.ObjectPath, passkey uint32) *dbus.Error {
log.Infof("[BluezAgent] RequestConfirmation: device=%s, passkey=%06d", device, passkey)
secrets, err := a.promptFor(device, "confirm", []string{"decision"}, nil)
if err != nil {
log.Warnf("[BluezAgent] RequestConfirmation failed: %v", err)
return a.errorFrom(err)
}
if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
log.Debugf("[BluezAgent] RequestConfirmation rejected by user")
return dbus.NewError("org.bluez.Error.Rejected", nil)
}
log.Infof("[BluezAgent] RequestConfirmation accepted")
return nil
}
func (a *BluezAgent) RequestAuthorization(device dbus.ObjectPath) *dbus.Error {
log.Infof("[BluezAgent] RequestAuthorization: device=%s", device)
secrets, err := a.promptFor(device, "authorize", []string{"decision"}, nil)
if err != nil {
log.Warnf("[BluezAgent] RequestAuthorization failed: %v", err)
return a.errorFrom(err)
}
if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
log.Debugf("[BluezAgent] RequestAuthorization rejected by user")
return dbus.NewError("org.bluez.Error.Rejected", nil)
}
log.Infof("[BluezAgent] RequestAuthorization accepted")
return nil
}
func (a *BluezAgent) AuthorizeService(device dbus.ObjectPath, uuid string) *dbus.Error {
log.Infof("[BluezAgent] AuthorizeService: device=%s, uuid=%s", device, uuid)
secrets, err := a.promptFor(device, "authorize-service:"+uuid, []string{"decision"}, nil)
if err != nil {
log.Warnf("[BluezAgent] AuthorizeService failed: %v", err)
return a.errorFrom(err)
}
if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
log.Debugf("[BluezAgent] AuthorizeService rejected by user")
return dbus.NewError("org.bluez.Error.Rejected", nil)
}
log.Infof("[BluezAgent] AuthorizeService accepted")
return nil
}
func (a *BluezAgent) Cancel() *dbus.Error {
log.Infof("[BluezAgent] Cancel called")
return nil
}
func (a *BluezAgent) Introspect() (string, *dbus.Error) {
return introspectXML, nil
}
func (a *BluezAgent) promptFor(device dbus.ObjectPath, requestType string, fields []string, displayValue *string) (map[string]string, error) {
if a.broker == nil {
return nil, fmt.Errorf("broker not initialized")
}
deviceName, deviceAddr := a.getDeviceInfo(device)
hints := []string{}
if displayValue != nil {
hints = append(hints, *displayValue)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var passkey *uint32
if requestType == "confirm" || requestType == "display-passkey" {
if displayValue != nil {
if pk, err := strconv.ParseUint(*displayValue, 10, 32); err == nil {
pk32 := uint32(pk)
passkey = &pk32
}
}
}
token, err := a.broker.Ask(ctx, PromptRequest{
DevicePath: string(device),
DeviceName: deviceName,
DeviceAddr: deviceAddr,
RequestType: requestType,
Fields: fields,
Hints: hints,
Passkey: passkey,
})
if err != nil {
return nil, fmt.Errorf("prompt creation failed: %w", err)
}
log.Infof("[BluezAgent] waiting for user response (token=%s)", token)
reply, err := a.broker.Wait(ctx, token)
if err != nil {
if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
return nil, err
}
if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
return nil, errdefs.ErrSecretPromptCancelled
}
return nil, err
}
if !reply.Accept && len(fields) > 0 {
return nil, errdefs.ErrSecretPromptCancelled
}
return reply.Secrets, nil
}
func (a *BluezAgent) getDeviceInfo(device dbus.ObjectPath) (string, string) {
obj := a.conn.Object(bluezService, device)
var name, alias, addr string
nameVar, err := obj.GetProperty(device1Iface + ".Name")
if err == nil {
if n, ok := nameVar.Value().(string); ok {
name = n
}
}
aliasVar, err := obj.GetProperty(device1Iface + ".Alias")
if err == nil {
if a, ok := aliasVar.Value().(string); ok {
alias = a
}
}
addrVar, err := obj.GetProperty(device1Iface + ".Address")
if err == nil {
if a, ok := addrVar.Value().(string); ok {
addr = a
}
}
if alias != "" {
return alias, addr
}
if name != "" {
return name, addr
}
return addr, addr
}
func (a *BluezAgent) errorFrom(err error) *dbus.Error {
if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
return dbus.NewError("org.bluez.Error.Canceled", nil)
}
if errors.Is(err, errdefs.ErrSecretPromptCancelled) {
return dbus.NewError("org.bluez.Error.Canceled", nil)
}
return dbus.MakeFailedError(err)
}

View File

@@ -0,0 +1,21 @@
package bluez
import (
"context"
"crypto/rand"
"encoding/hex"
)
type PromptBroker interface {
Ask(ctx context.Context, req PromptRequest) (token string, err error)
Wait(ctx context.Context, token string) (PromptReply, error)
Resolve(token string, reply PromptReply) error
}
func generateToken() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,220 @@
package bluez
import (
"context"
"testing"
"time"
)
func TestSubscriptionBrokerAskWait(t *testing.T) {
promptReceived := false
broker := NewSubscriptionBroker(func(p PairingPrompt) {
promptReceived = true
if p.Token == "" {
t.Error("expected token to be non-empty")
}
if p.DeviceName != "TestDevice" {
t.Errorf("expected DeviceName=TestDevice, got %s", p.DeviceName)
}
})
ctx := context.Background()
req := PromptRequest{
DevicePath: "/org/bluez/test",
DeviceName: "TestDevice",
DeviceAddr: "AA:BB:CC:DD:EE:FF",
RequestType: "pin",
Fields: []string{"pin"},
}
token, err := broker.Ask(ctx, req)
if err != nil {
t.Fatalf("Ask failed: %v", err)
}
if token == "" {
t.Fatal("expected non-empty token")
}
if !promptReceived {
t.Fatal("expected prompt broadcast to be called")
}
go func() {
time.Sleep(50 * time.Millisecond)
broker.Resolve(token, PromptReply{
Secrets: map[string]string{"pin": "1234"},
Accept: true,
})
}()
reply, err := broker.Wait(ctx, token)
if err != nil {
t.Fatalf("Wait failed: %v", err)
}
if reply.Secrets["pin"] != "1234" {
t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
}
if !reply.Accept {
t.Error("expected Accept=true")
}
}
func TestSubscriptionBrokerTimeout(t *testing.T) {
broker := NewSubscriptionBroker(nil)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
req := PromptRequest{
DevicePath: "/org/bluez/test",
DeviceName: "TestDevice",
RequestType: "passkey",
Fields: []string{"passkey"},
}
token, err := broker.Ask(ctx, req)
if err != nil {
t.Fatalf("Ask failed: %v", err)
}
_, err = broker.Wait(ctx, token)
if err == nil {
t.Fatal("expected timeout error")
}
}
func TestSubscriptionBrokerCancel(t *testing.T) {
broker := NewSubscriptionBroker(nil)
ctx := context.Background()
req := PromptRequest{
DevicePath: "/org/bluez/test",
DeviceName: "TestDevice",
RequestType: "confirm",
Fields: []string{"decision"},
}
token, err := broker.Ask(ctx, req)
if err != nil {
t.Fatalf("Ask failed: %v", err)
}
go func() {
time.Sleep(50 * time.Millisecond)
broker.Resolve(token, PromptReply{
Cancel: true,
})
}()
_, err = broker.Wait(ctx, token)
if err == nil {
t.Fatal("expected cancelled error")
}
}
func TestSubscriptionBrokerUnknownToken(t *testing.T) {
broker := NewSubscriptionBroker(nil)
ctx := context.Background()
_, err := broker.Wait(ctx, "invalid-token")
if err == nil {
t.Fatal("expected error for unknown token")
}
}
func TestGenerateToken(t *testing.T) {
token1, err := generateToken()
if err != nil {
t.Fatalf("generateToken failed: %v", err)
}
token2, err := generateToken()
if err != nil {
t.Fatalf("generateToken failed: %v", err)
}
if token1 == token2 {
t.Error("expected unique tokens")
}
if len(token1) != 32 {
t.Errorf("expected token length 32, got %d", len(token1))
}
}
func TestSubscriptionBrokerResolveUnknownToken(t *testing.T) {
broker := NewSubscriptionBroker(nil)
err := broker.Resolve("unknown-token", PromptReply{
Secrets: map[string]string{"test": "value"},
})
if err == nil {
t.Fatal("expected error for unknown token")
}
}
func TestSubscriptionBrokerMultipleRequests(t *testing.T) {
broker := NewSubscriptionBroker(nil)
ctx := context.Background()
req1 := PromptRequest{
DevicePath: "/org/bluez/test1",
DeviceName: "Device1",
RequestType: "pin",
Fields: []string{"pin"},
}
req2 := PromptRequest{
DevicePath: "/org/bluez/test2",
DeviceName: "Device2",
RequestType: "passkey",
Fields: []string{"passkey"},
}
token1, err := broker.Ask(ctx, req1)
if err != nil {
t.Fatalf("Ask1 failed: %v", err)
}
token2, err := broker.Ask(ctx, req2)
if err != nil {
t.Fatalf("Ask2 failed: %v", err)
}
if token1 == token2 {
t.Error("expected different tokens")
}
go func() {
time.Sleep(50 * time.Millisecond)
broker.Resolve(token1, PromptReply{
Secrets: map[string]string{"pin": "1234"},
Accept: true,
})
broker.Resolve(token2, PromptReply{
Secrets: map[string]string{"passkey": "567890"},
Accept: true,
})
}()
reply1, err := broker.Wait(ctx, token1)
if err != nil {
t.Fatalf("Wait1 failed: %v", err)
}
reply2, err := broker.Wait(ctx, token2)
if err != nil {
t.Fatalf("Wait2 failed: %v", err)
}
if reply1.Secrets["pin"] != "1234" {
t.Errorf("expected pin=1234, got %s", reply1.Secrets["pin"])
}
if reply2.Secrets["passkey"] != "567890" {
t.Errorf("expected passkey=567890, got %s", reply2.Secrets["passkey"])
}
}

View File

@@ -0,0 +1,260 @@
package bluez
import (
"encoding/json"
"fmt"
"net"
"github.com/AvengeMedia/danklinux/internal/server/models"
)
type Request struct {
ID int `json:"id,omitempty"`
Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"`
}
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type BluetoothEvent struct {
Type string `json:"type"`
Data BluetoothState `json:"data"`
}
func HandleRequest(conn net.Conn, req Request, manager *Manager) {
switch req.Method {
case "bluetooth.getState":
handleGetState(conn, req, manager)
case "bluetooth.startDiscovery":
handleStartDiscovery(conn, req, manager)
case "bluetooth.stopDiscovery":
handleStopDiscovery(conn, req, manager)
case "bluetooth.setPowered":
handleSetPowered(conn, req, manager)
case "bluetooth.pair":
handlePairDevice(conn, req, manager)
case "bluetooth.connect":
handleConnectDevice(conn, req, manager)
case "bluetooth.disconnect":
handleDisconnectDevice(conn, req, manager)
case "bluetooth.remove":
handleRemoveDevice(conn, req, manager)
case "bluetooth.trust":
handleTrustDevice(conn, req, manager)
case "bluetooth.untrust":
handleUntrustDevice(conn, req, manager)
case "bluetooth.subscribe":
handleSubscribe(conn, req, manager)
case "bluetooth.pairing.submit":
handlePairingSubmit(conn, req, manager)
case "bluetooth.pairing.cancel":
handlePairingCancel(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleGetState(conn net.Conn, req Request, manager *Manager) {
state := manager.GetState()
models.Respond(conn, req.ID, state)
}
func handleStartDiscovery(conn net.Conn, req Request, manager *Manager) {
if err := manager.StartDiscovery(); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "discovery started"})
}
func handleStopDiscovery(conn net.Conn, req Request, manager *Manager) {
if err := manager.StopDiscovery(); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "discovery stopped"})
}
func handleSetPowered(conn net.Conn, req Request, manager *Manager) {
powered, ok := req.Params["powered"].(bool)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'powered' parameter")
return
}
if err := manager.SetPowered(powered); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "powered state updated"})
}
func handlePairDevice(conn net.Conn, req Request, manager *Manager) {
devicePath, ok := req.Params["device"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
return
}
if err := manager.PairDevice(devicePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing initiated"})
}
func handleConnectDevice(conn net.Conn, req Request, manager *Manager) {
devicePath, ok := req.Params["device"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
return
}
if err := manager.ConnectDevice(devicePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "connecting"})
}
func handleDisconnectDevice(conn net.Conn, req Request, manager *Manager) {
devicePath, ok := req.Params["device"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
return
}
if err := manager.DisconnectDevice(devicePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "disconnected"})
}
func handleRemoveDevice(conn net.Conn, req Request, manager *Manager) {
devicePath, ok := req.Params["device"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
return
}
if err := manager.RemoveDevice(devicePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device removed"})
}
func handleTrustDevice(conn net.Conn, req Request, manager *Manager) {
devicePath, ok := req.Params["device"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
return
}
if err := manager.TrustDevice(devicePath, true); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device trusted"})
}
func handleUntrustDevice(conn net.Conn, req Request, manager *Manager) {
devicePath, ok := req.Params["device"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
return
}
if err := manager.TrustDevice(devicePath, false); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device untrusted"})
}
func handlePairingSubmit(conn net.Conn, req Request, manager *Manager) {
token, ok := req.Params["token"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
return
}
secretsRaw, ok := req.Params["secrets"].(map[string]interface{})
secrets := make(map[string]string)
if ok {
for k, v := range secretsRaw {
if str, ok := v.(string); ok {
secrets[k] = str
}
}
}
accept := false
if acceptParam, ok := req.Params["accept"].(bool); ok {
accept = acceptParam
}
if err := manager.SubmitPairing(token, secrets, accept); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing response submitted"})
}
func handlePairingCancel(conn net.Conn, req Request, manager *Manager) {
token, ok := req.Params["token"].(string)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
return
}
if err := manager.CancelPairing(token); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing cancelled"})
}
func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
clientID := fmt.Sprintf("client-%p", conn)
stateChan := manager.Subscribe(clientID)
defer manager.Unsubscribe(clientID)
initialState := manager.GetState()
event := BluetoothEvent{
Type: "state_changed",
Data: initialState,
}
if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
ID: req.ID,
Result: &event,
}); err != nil {
return
}
for state := range stateChan {
event := BluetoothEvent{
Type: "state_changed",
Data: state,
}
if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
Result: &event,
}); err != nil {
return
}
}
}

View File

@@ -0,0 +1,41 @@
package bluez
import (
"context"
"testing"
"time"
)
func TestBrokerIntegration(t *testing.T) {
broker := NewSubscriptionBroker(nil)
ctx := context.Background()
req := PromptRequest{
DevicePath: "/org/bluez/test",
DeviceName: "TestDevice",
RequestType: "pin",
Fields: []string{"pin"},
}
token, err := broker.Ask(ctx, req)
if err != nil {
t.Fatalf("Ask failed: %v", err)
}
go func() {
time.Sleep(50 * time.Millisecond)
broker.Resolve(token, PromptReply{
Secrets: map[string]string{"pin": "1234"},
Accept: true,
})
}()
reply, err := broker.Wait(ctx, token)
if err != nil {
t.Fatalf("Wait failed: %v", err)
}
if reply.Secrets["pin"] != "1234" {
t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
}
}

View File

@@ -0,0 +1,668 @@
package bluez
import (
"fmt"
"strings"
"sync"
"time"
"github.com/AvengeMedia/danklinux/internal/log"
"github.com/godbus/dbus/v5"
)
const (
adapter1Iface = "org.bluez.Adapter1"
objectMgrIface = "org.freedesktop.DBus.ObjectManager"
propertiesIface = "org.freedesktop.DBus.Properties"
)
func NewManager() (*Manager, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("system bus connection failed: %w", err)
}
m := &Manager{
state: &BluetoothState{
Powered: false,
Discovering: false,
Devices: []Device{},
PairedDevices: []Device{},
ConnectedDevices: []Device{},
},
stateMutex: sync.RWMutex{},
subscribers: make(map[string]chan BluetoothState),
subMutex: sync.RWMutex{},
stopChan: make(chan struct{}),
dbusConn: conn,
signals: make(chan *dbus.Signal, 256),
pairingSubscribers: make(map[string]chan PairingPrompt),
pairingSubMutex: sync.RWMutex{},
dirty: make(chan struct{}, 1),
pendingPairings: make(map[string]bool),
eventQueue: make(chan func(), 32),
}
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
m.promptBroker = broker
adapter, err := m.findAdapter()
if err != nil {
conn.Close()
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
}
m.adapterPath = adapter
if err := m.initialize(); err != nil {
conn.Close()
return nil, err
}
if err := m.startAgent(); err != nil {
conn.Close()
return nil, fmt.Errorf("agent start failed: %w", err)
}
if err := m.startSignalPump(); err != nil {
m.Close()
return nil, err
}
m.notifierWg.Add(1)
go m.notifier()
m.eventWg.Add(1)
go m.eventWorker()
return m, nil
}
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
return "", err
}
for path, interfaces := range objects {
if _, ok := interfaces[adapter1Iface]; ok {
log.Infof("[BluezManager] found adapter: %s", path)
return path, nil
}
}
return "", fmt.Errorf("no adapter found")
}
func (m *Manager) initialize() error {
if err := m.updateAdapterState(); err != nil {
return err
}
if err := m.updateDevices(); err != nil {
return err
}
return nil
}
func (m *Manager) updateAdapterState() error {
obj := m.dbusConn.Object(bluezService, m.adapterPath)
poweredVar, err := obj.GetProperty(adapter1Iface + ".Powered")
if err != nil {
return err
}
powered, _ := poweredVar.Value().(bool)
discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering")
if err != nil {
return err
}
discovering, _ := discoveringVar.Value().(bool)
m.stateMutex.Lock()
m.state.Powered = powered
m.state.Discovering = discovering
m.stateMutex.Unlock()
return nil
}
func (m *Manager) updateDevices() error {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
return err
}
devices := []Device{}
paired := []Device{}
connected := []Device{}
for path, interfaces := range objects {
devProps, ok := interfaces[device1Iface]
if !ok {
continue
}
if !strings.HasPrefix(string(path), string(m.adapterPath)+"/") {
continue
}
dev := m.deviceFromProps(string(path), devProps)
devices = append(devices, dev)
if dev.Paired {
paired = append(paired, dev)
}
if dev.Connected {
connected = append(connected, dev)
}
}
m.stateMutex.Lock()
m.state.Devices = devices
m.state.PairedDevices = paired
m.state.ConnectedDevices = connected
m.stateMutex.Unlock()
return nil
}
func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
dev := Device{Path: path}
if v, ok := props["Address"]; ok {
if addr, ok := v.Value().(string); ok {
dev.Address = addr
}
}
if v, ok := props["Name"]; ok {
if name, ok := v.Value().(string); ok {
dev.Name = name
}
}
if v, ok := props["Alias"]; ok {
if alias, ok := v.Value().(string); ok {
dev.Alias = alias
}
}
if v, ok := props["Paired"]; ok {
if paired, ok := v.Value().(bool); ok {
dev.Paired = paired
}
}
if v, ok := props["Trusted"]; ok {
if trusted, ok := v.Value().(bool); ok {
dev.Trusted = trusted
}
}
if v, ok := props["Blocked"]; ok {
if blocked, ok := v.Value().(bool); ok {
dev.Blocked = blocked
}
}
if v, ok := props["Connected"]; ok {
if connected, ok := v.Value().(bool); ok {
dev.Connected = connected
}
}
if v, ok := props["Class"]; ok {
if class, ok := v.Value().(uint32); ok {
dev.Class = class
}
}
if v, ok := props["Icon"]; ok {
if icon, ok := v.Value().(string); ok {
dev.Icon = icon
}
}
if v, ok := props["RSSI"]; ok {
if rssi, ok := v.Value().(int16); ok {
dev.RSSI = rssi
}
}
if v, ok := props["LegacyPairing"]; ok {
if legacy, ok := v.Value().(bool); ok {
dev.LegacyPairing = legacy
}
}
return dev
}
func (m *Manager) startAgent() error {
if m.promptBroker == nil {
return fmt.Errorf("prompt broker not initialized")
}
agent, err := NewBluezAgent(m.promptBroker)
if err != nil {
return err
}
m.agent = agent
return nil
}
func (m *Manager) startSignalPump() error {
m.dbusConn.Signal(m.signals)
if err := m.dbusConn.AddMatchSignal(
dbus.WithMatchInterface(propertiesIface),
dbus.WithMatchMember("PropertiesChanged"),
); err != nil {
return err
}
if err := m.dbusConn.AddMatchSignal(
dbus.WithMatchInterface(objectMgrIface),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return err
}
if err := m.dbusConn.AddMatchSignal(
dbus.WithMatchInterface(objectMgrIface),
dbus.WithMatchMember("InterfacesRemoved"),
); err != nil {
return err
}
m.sigWG.Add(1)
go func() {
defer m.sigWG.Done()
for {
select {
case <-m.stopChan:
return
case sig, ok := <-m.signals:
if !ok {
return
}
if sig == nil {
continue
}
m.handleSignal(sig)
}
}
}()
return nil
}
func (m *Manager) handleSignal(sig *dbus.Signal) {
switch sig.Name {
case propertiesIface + ".PropertiesChanged":
if len(sig.Body) < 2 {
return
}
iface, ok := sig.Body[0].(string)
if !ok {
return
}
changed, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
return
}
switch iface {
case adapter1Iface:
if strings.HasPrefix(string(sig.Path), string(m.adapterPath)) {
m.handleAdapterPropertiesChanged(changed)
}
case device1Iface:
m.handleDevicePropertiesChanged(sig.Path, changed)
}
case objectMgrIface + ".InterfacesAdded":
m.notifySubscribers()
case objectMgrIface + ".InterfacesRemoved":
m.notifySubscribers()
}
}
func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant) {
m.stateMutex.Lock()
dirty := false
if v, ok := changed["Powered"]; ok {
if powered, ok := v.Value().(bool); ok {
m.state.Powered = powered
dirty = true
}
}
if v, ok := changed["Discovering"]; ok {
if discovering, ok := v.Value().(bool); ok {
m.state.Discovering = discovering
dirty = true
}
}
m.stateMutex.Unlock()
if dirty {
m.notifySubscribers()
}
}
func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
pairedVar, hasPaired := changed["Paired"]
_, hasConnected := changed["Connected"]
_, hasTrusted := changed["Trusted"]
if hasPaired {
if paired, ok := pairedVar.Value().(bool); ok && paired {
devicePath := string(path)
m.pendingPairingsMux.Lock()
wasPending := m.pendingPairings[devicePath]
if wasPending {
delete(m.pendingPairings, devicePath)
}
m.pendingPairingsMux.Unlock()
if wasPending {
select {
case m.eventQueue <- func() {
time.Sleep(300 * time.Millisecond)
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
if err := m.ConnectDevice(devicePath); err != nil {
log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
}
}:
default:
}
}
}
}
if hasPaired || hasConnected || hasTrusted {
select {
case m.eventQueue <- func() {
time.Sleep(100 * time.Millisecond)
m.updateDevices()
m.notifySubscribers()
}:
default:
}
}
}
func (m *Manager) eventWorker() {
defer m.eventWg.Done()
for {
select {
case <-m.stopChan:
return
case event := <-m.eventQueue:
event()
}
}
}
func (m *Manager) notifier() {
defer m.notifierWg.Done()
const minGap = 200 * time.Millisecond
timer := time.NewTimer(minGap)
timer.Stop()
var pending bool
for {
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.dirty:
if pending {
continue
}
pending = true
timer.Reset(minGap)
case <-timer.C:
if !pending {
continue
}
m.updateDevices()
m.subMutex.RLock()
if len(m.subscribers) == 0 {
m.subMutex.RUnlock()
pending = false
continue
}
currentState := m.snapshotState()
if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, &currentState) {
m.subMutex.RUnlock()
pending = false
continue
}
for _, ch := range m.subscribers {
select {
case ch <- currentState:
default:
}
}
m.subMutex.RUnlock()
stateCopy := currentState
m.lastNotifiedState = &stateCopy
pending = false
}
}
}
func (m *Manager) notifySubscribers() {
select {
case m.dirty <- struct{}{}:
default:
}
}
func (m *Manager) GetState() BluetoothState {
return m.snapshotState()
}
func (m *Manager) snapshotState() BluetoothState {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
s := *m.state
s.Devices = append([]Device(nil), m.state.Devices...)
s.PairedDevices = append([]Device(nil), m.state.PairedDevices...)
s.ConnectedDevices = append([]Device(nil), m.state.ConnectedDevices...)
return s
}
func (m *Manager) Subscribe(id string) chan BluetoothState {
ch := make(chan BluetoothState, 64)
m.subMutex.Lock()
m.subscribers[id] = ch
m.subMutex.Unlock()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subMutex.Lock()
if ch, ok := m.subscribers[id]; ok {
close(ch)
delete(m.subscribers, id)
}
m.subMutex.Unlock()
}
func (m *Manager) SubscribePairing(id string) chan PairingPrompt {
ch := make(chan PairingPrompt, 16)
m.pairingSubMutex.Lock()
m.pairingSubscribers[id] = ch
m.pairingSubMutex.Unlock()
return ch
}
func (m *Manager) UnsubscribePairing(id string) {
m.pairingSubMutex.Lock()
if ch, ok := m.pairingSubscribers[id]; ok {
close(ch)
delete(m.pairingSubscribers, id)
}
m.pairingSubMutex.Unlock()
}
func (m *Manager) broadcastPairingPrompt(prompt PairingPrompt) {
m.pairingSubMutex.RLock()
defer m.pairingSubMutex.RUnlock()
for _, ch := range m.pairingSubscribers {
select {
case ch <- prompt:
default:
}
}
}
func (m *Manager) SubmitPairing(token string, secrets map[string]string, accept bool) error {
if m.promptBroker == nil {
return fmt.Errorf("prompt broker not initialized")
}
return m.promptBroker.Resolve(token, PromptReply{
Secrets: secrets,
Accept: accept,
Cancel: false,
})
}
func (m *Manager) CancelPairing(token string) error {
if m.promptBroker == nil {
return fmt.Errorf("prompt broker not initialized")
}
return m.promptBroker.Resolve(token, PromptReply{
Cancel: true,
})
}
func (m *Manager) StartDiscovery() error {
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(adapter1Iface+".StartDiscovery", 0).Err
}
func (m *Manager) StopDiscovery() error {
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(adapter1Iface+".StopDiscovery", 0).Err
}
func (m *Manager) SetPowered(powered bool) error {
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
}
func (m *Manager) PairDevice(devicePath string) error {
m.pendingPairingsMux.Lock()
m.pendingPairings[devicePath] = true
m.pendingPairingsMux.Unlock()
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
err := obj.Call(device1Iface+".Pair", 0).Err
if err != nil {
m.pendingPairingsMux.Lock()
delete(m.pendingPairings, devicePath)
m.pendingPairingsMux.Unlock()
}
return err
}
func (m *Manager) ConnectDevice(devicePath string) error {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
return obj.Call(device1Iface+".Connect", 0).Err
}
func (m *Manager) DisconnectDevice(devicePath string) error {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
return obj.Call(device1Iface+".Disconnect", 0).Err
}
func (m *Manager) RemoveDevice(devicePath string) error {
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(adapter1Iface+".RemoveDevice", 0, dbus.ObjectPath(devicePath)).Err
}
func (m *Manager) TrustDevice(devicePath string, trusted bool) error {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
return obj.Call(propertiesIface+".Set", 0, device1Iface, "Trusted", dbus.MakeVariant(trusted)).Err
}
func (m *Manager) Close() {
close(m.stopChan)
m.notifierWg.Wait()
m.eventWg.Wait()
m.sigWG.Wait()
if m.signals != nil {
m.dbusConn.RemoveSignal(m.signals)
close(m.signals)
}
if m.agent != nil {
m.agent.Close()
}
m.subMutex.Lock()
for _, ch := range m.subscribers {
close(ch)
}
m.subscribers = make(map[string]chan BluetoothState)
m.subMutex.Unlock()
m.pairingSubMutex.Lock()
for _, ch := range m.pairingSubscribers {
close(ch)
}
m.pairingSubscribers = make(map[string]chan PairingPrompt)
m.pairingSubMutex.Unlock()
if m.dbusConn != nil {
m.dbusConn.Close()
}
}
func stateChanged(old, new *BluetoothState) bool {
if old.Powered != new.Powered {
return true
}
if old.Discovering != new.Discovering {
return true
}
if len(old.Devices) != len(new.Devices) {
return true
}
if len(old.PairedDevices) != len(new.PairedDevices) {
return true
}
if len(old.ConnectedDevices) != len(new.ConnectedDevices) {
return true
}
for i := range old.Devices {
if old.Devices[i].Path != new.Devices[i].Path {
return true
}
if old.Devices[i].Paired != new.Devices[i].Paired {
return true
}
if old.Devices[i].Connected != new.Devices[i].Connected {
return true
}
}
return false
}

View File

@@ -0,0 +1,99 @@
package bluez
import (
"context"
"fmt"
"sync"
"github.com/AvengeMedia/danklinux/internal/errdefs"
)
type SubscriptionBroker struct {
mu sync.RWMutex
pending map[string]chan PromptReply
requests map[string]PromptRequest
broadcastPrompt func(PairingPrompt)
}
func NewSubscriptionBroker(broadcastPrompt func(PairingPrompt)) PromptBroker {
return &SubscriptionBroker{
pending: make(map[string]chan PromptReply),
requests: make(map[string]PromptRequest),
broadcastPrompt: broadcastPrompt,
}
}
func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
token, err := generateToken()
if err != nil {
return "", err
}
replyChan := make(chan PromptReply, 1)
b.mu.Lock()
b.pending[token] = replyChan
b.requests[token] = req
b.mu.Unlock()
if b.broadcastPrompt != nil {
prompt := PairingPrompt{
Token: token,
DevicePath: req.DevicePath,
DeviceName: req.DeviceName,
DeviceAddr: req.DeviceAddr,
RequestType: req.RequestType,
Fields: req.Fields,
Hints: req.Hints,
Passkey: req.Passkey,
}
b.broadcastPrompt(prompt)
}
return token, nil
}
func (b *SubscriptionBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
b.mu.RLock()
replyChan, exists := b.pending[token]
b.mu.RUnlock()
if !exists {
return PromptReply{}, fmt.Errorf("unknown token: %s", token)
}
select {
case <-ctx.Done():
b.cleanup(token)
return PromptReply{}, errdefs.ErrSecretPromptTimeout
case reply := <-replyChan:
b.cleanup(token)
if reply.Cancel {
return reply, errdefs.ErrSecretPromptCancelled
}
return reply, nil
}
}
func (b *SubscriptionBroker) Resolve(token string, reply PromptReply) error {
b.mu.RLock()
replyChan, exists := b.pending[token]
b.mu.RUnlock()
if !exists {
return fmt.Errorf("unknown or expired token: %s", token)
}
select {
case replyChan <- reply:
return nil
default:
return fmt.Errorf("failed to deliver reply for token: %s", token)
}
}
func (b *SubscriptionBroker) cleanup(token string) {
b.mu.Lock()
delete(b.pending, token)
delete(b.requests, token)
b.mu.Unlock()
}

View File

@@ -0,0 +1,80 @@
package bluez
import (
"sync"
"github.com/godbus/dbus/v5"
)
type BluetoothState struct {
Powered bool `json:"powered"`
Discovering bool `json:"discovering"`
Devices []Device `json:"devices"`
PairedDevices []Device `json:"pairedDevices"`
ConnectedDevices []Device `json:"connectedDevices"`
}
type Device struct {
Path string `json:"path"`
Address string `json:"address"`
Name string `json:"name"`
Alias string `json:"alias"`
Paired bool `json:"paired"`
Trusted bool `json:"trusted"`
Blocked bool `json:"blocked"`
Connected bool `json:"connected"`
Class uint32 `json:"class"`
Icon string `json:"icon"`
RSSI int16 `json:"rssi"`
LegacyPairing bool `json:"legacyPairing"`
}
type PromptRequest struct {
DevicePath string `json:"devicePath"`
DeviceName string `json:"deviceName"`
DeviceAddr string `json:"deviceAddr"`
RequestType string `json:"requestType"`
Fields []string `json:"fields"`
Hints []string `json:"hints"`
Passkey *uint32 `json:"passkey,omitempty"`
}
type PromptReply struct {
Secrets map[string]string `json:"secrets"`
Accept bool `json:"accept"`
Cancel bool `json:"cancel"`
}
type PairingPrompt struct {
Token string `json:"token"`
DevicePath string `json:"devicePath"`
DeviceName string `json:"deviceName"`
DeviceAddr string `json:"deviceAddr"`
RequestType string `json:"requestType"`
Fields []string `json:"fields"`
Hints []string `json:"hints"`
Passkey *uint32 `json:"passkey,omitempty"`
}
type Manager struct {
state *BluetoothState
stateMutex sync.RWMutex
subscribers map[string]chan BluetoothState
subMutex sync.RWMutex
stopChan chan struct{}
dbusConn *dbus.Conn
signals chan *dbus.Signal
sigWG sync.WaitGroup
agent *BluezAgent
promptBroker PromptBroker
pairingSubscribers map[string]chan PairingPrompt
pairingSubMutex sync.RWMutex
dirty chan struct{}
notifierWg sync.WaitGroup
lastNotifiedState *BluetoothState
adapterPath dbus.ObjectPath
pendingPairings map[string]bool
pendingPairingsMux sync.Mutex
eventQueue chan func()
eventWg sync.WaitGroup
}

View File

@@ -0,0 +1,210 @@
package bluez
import (
"encoding/json"
"testing"
)
func TestBluetoothStateJSON(t *testing.T) {
state := BluetoothState{
Powered: true,
Discovering: false,
Devices: []Device{
{
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
Address: "AA:BB:CC:DD:EE:FF",
Name: "TestDevice",
Alias: "My Device",
Paired: true,
Trusted: false,
Connected: true,
Class: 0x240418,
Icon: "audio-headset",
RSSI: -50,
},
},
PairedDevices: []Device{
{
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
Address: "AA:BB:CC:DD:EE:FF",
Paired: true,
},
},
ConnectedDevices: []Device{
{
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
Address: "AA:BB:CC:DD:EE:FF",
Connected: true,
},
},
}
data, err := json.Marshal(state)
if err != nil {
t.Fatalf("failed to marshal state: %v", err)
}
var decoded BluetoothState
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal state: %v", err)
}
if decoded.Powered != state.Powered {
t.Errorf("expected Powered=%v, got %v", state.Powered, decoded.Powered)
}
if len(decoded.Devices) != 1 {
t.Fatalf("expected 1 device, got %d", len(decoded.Devices))
}
if decoded.Devices[0].Address != "AA:BB:CC:DD:EE:FF" {
t.Errorf("expected address AA:BB:CC:DD:EE:FF, got %s", decoded.Devices[0].Address)
}
}
func TestDeviceJSON(t *testing.T) {
device := Device{
Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
Address: "AA:BB:CC:DD:EE:FF",
Name: "TestDevice",
Alias: "My Device",
Paired: true,
Trusted: true,
Blocked: false,
Connected: true,
Class: 0x240418,
Icon: "audio-headset",
RSSI: -50,
LegacyPairing: false,
}
data, err := json.Marshal(device)
if err != nil {
t.Fatalf("failed to marshal device: %v", err)
}
var decoded Device
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal device: %v", err)
}
if decoded.Address != device.Address {
t.Errorf("expected Address=%s, got %s", device.Address, decoded.Address)
}
if decoded.Name != device.Name {
t.Errorf("expected Name=%s, got %s", device.Name, decoded.Name)
}
if decoded.Paired != device.Paired {
t.Errorf("expected Paired=%v, got %v", device.Paired, decoded.Paired)
}
if decoded.RSSI != device.RSSI {
t.Errorf("expected RSSI=%d, got %d", device.RSSI, decoded.RSSI)
}
}
func TestPairingPromptJSON(t *testing.T) {
passkey := uint32(123456)
prompt := PairingPrompt{
Token: "test-token",
DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
DeviceName: "TestDevice",
DeviceAddr: "AA:BB:CC:DD:EE:FF",
RequestType: "confirm",
Fields: []string{"decision"},
Hints: []string{},
Passkey: &passkey,
}
data, err := json.Marshal(prompt)
if err != nil {
t.Fatalf("failed to marshal prompt: %v", err)
}
var decoded PairingPrompt
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal prompt: %v", err)
}
if decoded.Token != prompt.Token {
t.Errorf("expected Token=%s, got %s", prompt.Token, decoded.Token)
}
if decoded.DeviceName != prompt.DeviceName {
t.Errorf("expected DeviceName=%s, got %s", prompt.DeviceName, decoded.DeviceName)
}
if decoded.Passkey == nil {
t.Fatal("expected non-nil Passkey")
}
if *decoded.Passkey != *prompt.Passkey {
t.Errorf("expected Passkey=%d, got %d", *prompt.Passkey, *decoded.Passkey)
}
}
func TestPromptReplyJSON(t *testing.T) {
reply := PromptReply{
Secrets: map[string]string{
"pin": "1234",
"passkey": "567890",
},
Accept: true,
Cancel: false,
}
data, err := json.Marshal(reply)
if err != nil {
t.Fatalf("failed to marshal reply: %v", err)
}
var decoded PromptReply
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal reply: %v", err)
}
if decoded.Secrets["pin"] != reply.Secrets["pin"] {
t.Errorf("expected pin=%s, got %s", reply.Secrets["pin"], decoded.Secrets["pin"])
}
if decoded.Accept != reply.Accept {
t.Errorf("expected Accept=%v, got %v", reply.Accept, decoded.Accept)
}
}
func TestPromptRequestJSON(t *testing.T) {
passkey := uint32(123456)
req := PromptRequest{
DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
DeviceName: "TestDevice",
DeviceAddr: "AA:BB:CC:DD:EE:FF",
RequestType: "confirm",
Fields: []string{"decision"},
Hints: []string{"hint1", "hint2"},
Passkey: &passkey,
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}
var decoded PromptRequest
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal request: %v", err)
}
if decoded.DevicePath != req.DevicePath {
t.Errorf("expected DevicePath=%s, got %s", req.DevicePath, decoded.DevicePath)
}
if decoded.RequestType != req.RequestType {
t.Errorf("expected RequestType=%s, got %s", req.RequestType, decoded.RequestType)
}
if len(decoded.Fields) != len(req.Fields) {
t.Errorf("expected %d fields, got %d", len(req.Fields), len(decoded.Fields))
}
}

View File

@@ -0,0 +1,485 @@
package brightness
import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
"github.com/AvengeMedia/danklinux/internal/log"
"golang.org/x/sys/unix"
)
const (
I2C_SLAVE = 0x0703
DDCCI_ADDR = 0x37
DDCCI_VCP_GET = 0x01
DDCCI_VCP_SET = 0x03
VCP_BRIGHTNESS = 0x10
DDC_SOURCE_ADDR = 0x51
)
func NewDDCBackend() (*DDCBackend, error) {
b := &DDCBackend{
devices: make(map[string]*ddcDevice),
scanInterval: 30 * time.Second,
debounceTimers: make(map[string]*time.Timer),
debouncePending: make(map[string]ddcPendingSet),
}
if err := b.scanI2CDevices(); err != nil {
return nil, err
}
return b, nil
}
func (b *DDCBackend) scanI2CDevices() error {
b.scanMutex.Lock()
lastScan := b.lastScan
b.scanMutex.Unlock()
if time.Since(lastScan) < b.scanInterval {
return nil
}
b.scanMutex.Lock()
defer b.scanMutex.Unlock()
if time.Since(b.lastScan) < b.scanInterval {
return nil
}
b.devicesMutex.Lock()
defer b.devicesMutex.Unlock()
b.devices = make(map[string]*ddcDevice)
for i := 0; i < 32; i++ {
busPath := fmt.Sprintf("/dev/i2c-%d", i)
if _, err := os.Stat(busPath); os.IsNotExist(err) {
continue
}
// Skip SMBus, GPU internal buses (e.g. AMDGPU SMU) to prevent GPU hangs
if isIgnorableI2CBus(i) {
log.Debugf("Skipping ignorable i2c-%d", i)
continue
}
dev, err := b.probeDDCDevice(i)
if err != nil || dev == nil {
continue
}
id := fmt.Sprintf("ddc:i2c-%d", i)
dev.id = id
b.devices[id] = dev
log.Debugf("found DDC device on i2c-%d", i)
}
b.lastScan = time.Now()
return nil
}
func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
busPath := fmt.Sprintf("/dev/i2c-%d", bus)
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
if err != nil {
return nil, err
}
defer syscall.Close(fd)
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
return nil, errno
}
dummy := make([]byte, 32)
syscall.Read(fd, dummy)
writebuf := []byte{0x00}
n, err := syscall.Write(fd, writebuf)
if err == nil && n == len(writebuf) {
name := b.getDDCName(bus)
dev := &ddcDevice{
bus: bus,
addr: DDCCI_ADDR,
name: name,
}
b.readInitialBrightness(fd, dev)
return dev, nil
}
readbuf := make([]byte, 4)
n, err = syscall.Read(fd, readbuf)
if err != nil || n == 0 {
return nil, fmt.Errorf("x37 unresponsive")
}
name := b.getDDCName(bus)
dev := &ddcDevice{
bus: bus,
addr: DDCCI_ADDR,
name: name,
}
b.readInitialBrightness(fd, dev)
return dev, nil
}
func (b *DDCBackend) getDDCName(bus int) string {
sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus)
data, err := os.ReadFile(sysfsPath)
if err != nil {
return fmt.Sprintf("I2C-%d", bus)
}
name := strings.TrimSpace(string(data))
if name == "" {
name = fmt.Sprintf("I2C-%d", bus)
}
return name
}
func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err != nil {
log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
return
}
dev.max = cap.max
dev.lastBrightness = cap.current
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
}
func (b *DDCBackend) GetDevices() ([]Device, error) {
if err := b.scanI2CDevices(); err != nil {
log.Debugf("DDC scan error: %v", err)
}
b.devicesMutex.Lock()
defer b.devicesMutex.Unlock()
devices := make([]Device, 0, len(b.devices))
for id, dev := range b.devices {
devices = append(devices, Device{
Class: ClassDDC,
ID: id,
Name: dev.name,
Current: dev.lastBrightness,
Max: dev.max,
CurrentPercent: dev.lastBrightness,
Backend: "ddc",
})
}
return devices, nil
}
func (b *DDCBackend) SetBrightness(id string, value int, exponential bool, callback func()) error {
return b.SetBrightnessWithExponent(id, value, exponential, 1.2, callback)
}
func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential bool, exponent float64, callback func()) error {
b.devicesMutex.RLock()
_, ok := b.devices[id]
b.devicesMutex.RUnlock()
if !ok {
return fmt.Errorf("device not found: %s", id)
}
if value < 0 {
return fmt.Errorf("value out of range: %d", value)
}
b.debounceMutex.Lock()
defer b.debounceMutex.Unlock()
b.debouncePending[id] = ddcPendingSet{
percent: value,
callback: callback,
}
if timer, exists := b.debounceTimers[id]; exists {
timer.Reset(200 * time.Millisecond)
} else {
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
b.debounceMutex.Lock()
pending, exists := b.debouncePending[id]
if exists {
delete(b.debouncePending, id)
}
b.debounceMutex.Unlock()
if !exists {
return
}
err := b.setBrightnessImmediateWithExponent(id, pending.percent, exponential, exponent)
if err != nil {
log.Debugf("Failed to set brightness for %s: %v", id, err)
}
if pending.callback != nil {
pending.callback()
}
})
}
return nil
}
func (b *DDCBackend) setBrightnessImmediate(id string, value int, exponential bool) error {
return b.setBrightnessImmediateWithExponent(id, value, exponential, 1.2)
}
func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int, exponential bool, exponent float64) error {
b.devicesMutex.RLock()
dev, ok := b.devices[id]
b.devicesMutex.RUnlock()
if !ok {
return fmt.Errorf("device not found: %s", id)
}
busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
if err != nil {
return fmt.Errorf("open i2c device: %w", err)
}
defer syscall.Close(fd)
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 {
return fmt.Errorf("set i2c slave addr: %w", errno)
}
max := dev.max
if max == 0 {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err != nil {
return fmt.Errorf("get current capability: %w", err)
}
max = cap.max
b.devicesMutex.Lock()
dev.max = max
b.devicesMutex.Unlock()
}
if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil {
return fmt.Errorf("set vcp feature: %w", err)
}
log.Debugf("set %s to %d/%d", id, value, max)
b.devicesMutex.Lock()
dev.max = max
dev.lastBrightness = value
b.devicesMutex.Unlock()
return nil
}
func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
for flushTry := 0; flushTry < 3; flushTry++ {
dummy := make([]byte, 32)
n, _ := syscall.Read(fd, dummy)
if n == 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
data := []byte{
DDCCI_VCP_GET,
vcp,
}
payload := []byte{
DDC_SOURCE_ADDR,
byte(len(data)) | 0x80,
}
payload = append(payload, data...)
payload = append(payload, ddcciChecksum(payload))
n, err := syscall.Write(fd, payload)
if err != nil || n != len(payload) {
return nil, fmt.Errorf("write i2c: %w", err)
}
time.Sleep(50 * time.Millisecond)
pollFds := []unix.PollFd{
{
Fd: int32(fd),
Events: unix.POLLIN,
},
}
pollTimeout := 200
pollResult, err := unix.Poll(pollFds, pollTimeout)
if err != nil {
return nil, fmt.Errorf("poll i2c: %w", err)
}
if pollResult == 0 {
return nil, fmt.Errorf("poll timeout after %dms", pollTimeout)
}
if pollFds[0].Revents&unix.POLLIN == 0 {
return nil, fmt.Errorf("poll returned but POLLIN not set")
}
response := make([]byte, 12)
n, err = syscall.Read(fd, response)
if err != nil || n < 8 {
return nil, fmt.Errorf("read i2c: %w", err)
}
if response[0] != 0x6E || response[2] != 0x02 {
return nil, fmt.Errorf("invalid ddc response")
}
resultCode := response[3]
if resultCode != 0x00 {
return nil, fmt.Errorf("vcp feature not supported")
}
responseVCP := response[4]
if responseVCP != vcp {
return nil, fmt.Errorf("vcp mismatch: wanted 0x%02x, got 0x%02x", vcp, responseVCP)
}
maxHigh := response[6]
maxLow := response[7]
currentHigh := response[8]
currentLow := response[9]
max := int(binary.BigEndian.Uint16([]byte{maxHigh, maxLow}))
current := int(binary.BigEndian.Uint16([]byte{currentHigh, currentLow}))
return &ddcCapability{
vcp: vcp,
max: max,
current: current,
}, nil
}
func ddcciChecksum(payload []byte) byte {
sum := byte(0x6E)
for _, b := range payload {
sum ^= b
}
return sum
}
func (b *DDCBackend) setVCPFeature(fd int, vcp byte, value int) error {
data := []byte{
DDCCI_VCP_SET,
vcp,
byte(value >> 8),
byte(value & 0xFF),
}
payload := []byte{
DDC_SOURCE_ADDR,
byte(len(data)) | 0x80,
}
payload = append(payload, data...)
payload = append(payload, ddcciChecksum(payload))
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
return fmt.Errorf("set i2c slave for write: %w", errno)
}
n, err := syscall.Write(fd, payload)
if err != nil || n != len(payload) {
return fmt.Errorf("write i2c: wrote %d/%d: %w", n, len(payload), err)
}
time.Sleep(50 * time.Millisecond)
return nil
}
func (b *DDCBackend) percentToValue(percent int, max int, exponential bool) int {
const minValue = 1
if percent == 0 {
return minValue
}
usableRange := max - minValue
var value int
if exponential {
const exponent = 2.0
normalizedPercent := float64(percent) / 100.0
hardwarePercent := math.Pow(normalizedPercent, 1.0/exponent)
value = minValue + int(math.Round(hardwarePercent*float64(usableRange)))
} else {
value = minValue + ((percent - 1) * usableRange / 99)
}
if value < minValue {
value = minValue
}
if value > max {
value = max
}
return value
}
func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
const minValue = 1
if max == 0 {
return 0
}
if value <= minValue {
return 1
}
usableRange := max - minValue
if usableRange == 0 {
return 100
}
var percent int
if exponential {
const exponent = 2.0
linearPercent := 1 + ((value - minValue) * 99 / usableRange)
normalizedLinear := float64(linearPercent) / 100.0
expPercent := math.Pow(normalizedLinear, exponent)
percent = int(math.Round(expPercent * 100.0))
} else {
percent = 1 + ((value - minValue) * 99 / usableRange)
}
if percent > 100 {
percent = 100
}
if percent < 1 {
percent = 1
}
return percent
}
func (b *DDCBackend) Close() {
}
var _ = unsafe.Sizeof(0)
var _ = filepath.Join

Some files were not shown because too many files have changed in this diff Show More