1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00

core: add slices, paths, exec utils

This commit is contained in:
bbedward
2025-12-09 15:28:19 -05:00
parent e307de83e2
commit aeacf109eb
44 changed files with 931 additions and 625 deletions

View File

@@ -211,45 +211,13 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
exponential, _ := cmd.Flags().GetBool("exponential")
exponent, _ := cmd.Flags().GetFloat64("exponent")
// For backlight/leds devices, try logind backend first (requires D-Bus connection)
parts := strings.SplitN(deviceID, ":", 2)
if len(parts) == 2 && (parts[0] == "backlight" || parts[0] == "leds") {
subsystem := parts[0]
name := parts[1]
// Initialize backends needed for logind approach
sysfs, err := brightness.NewSysfsBackend()
if err != nil {
log.Debugf("NewSysfsBackend failed: %v", err)
} else {
logind, err := brightness.NewLogindBackend()
if err != nil {
log.Debugf("NewLogindBackend failed: %v", err)
} else {
defer logind.Close()
// Get device info to convert percent to value
dev, err := sysfs.GetDevice(deviceID)
if err == nil {
// Calculate hardware value using the same logic as Manager.setViaSysfsWithLogind
value := sysfs.PercentToValueWithExponent(percent, dev, exponential, exponent)
// Call logind with hardware value
if err := logind.SetBrightness(subsystem, name, uint32(value)); err == nil {
log.Debugf("set %s to %d%% (%d) via logind", deviceID, percent, value)
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
} else {
log.Debugf("logind.SetBrightness failed: %v", err)
}
} else {
log.Debugf("sysfs.GetDeviceByID failed: %v", err)
}
}
if ok := tryLogindBrightness(parts[0], parts[1], deviceID, percent, exponential, exponent); ok {
return
}
}
// Fallback to direct sysfs (requires write permissions)
sysfs, err := brightness.NewSysfsBackend()
if err == nil {
if err := sysfs.SetBrightnessWithExponent(deviceID, percent, exponential, exponent); err == nil {
@@ -280,6 +248,37 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
log.Fatalf("Failed to set brightness for device: %s", deviceID)
}
func tryLogindBrightness(subsystem, name, deviceID string, percent int, exponential bool, exponent float64) bool {
sysfs, err := brightness.NewSysfsBackend()
if err != nil {
log.Debugf("NewSysfsBackend failed: %v", err)
return false
}
logind, err := brightness.NewLogindBackend()
if err != nil {
log.Debugf("NewLogindBackend failed: %v", err)
return false
}
defer logind.Close()
dev, err := sysfs.GetDevice(deviceID)
if err != nil {
log.Debugf("sysfs.GetDeviceByID failed: %v", err)
return false
}
value := sysfs.PercentToValueWithExponent(percent, dev, exponential, exponent)
if err := logind.SetBrightness(subsystem, name, uint32(value)); err != nil {
log.Debugf("logind.SetBrightness failed: %v", err)
return false
}
log.Debugf("set %s to %d%% (%d) via logind", deviceID, percent, value)
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return true
}
func getBrightnessDevices(includeDDC bool) []string {
allDevices := getAllBrightnessDevices(includeDDC)

View File

@@ -152,6 +152,24 @@ var pluginsUninstallCmd = &cobra.Command{
},
}
var pluginsUpdateCmd = &cobra.Command{
Use: "update <plugin-id>",
Short: "Update a plugin by ID",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
if err := updatePluginCLI(args[0]); err != nil {
log.Fatalf("Error updating plugin: %v", err)
}
},
}
func runVersion(cmd *cobra.Command, args []string) {
printASCII()
fmt.Printf("%s\n", formatVersion(Version))
@@ -408,49 +426,70 @@ func uninstallPluginCLI(idOrName string) error {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(idOrName, pluginList)
// First, try to find by ID (preferred method)
var plugin *plugins.Plugin
for _, p := range pluginList {
if p.ID == idOrName {
plugin = &p
break
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
}
// Fallback to name for backward compatibility
if plugin == nil {
for _, p := range pluginList {
if p.Name == idOrName {
plugin = &p
break
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
fmt.Printf("Uninstalling plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Uninstall(*plugin); err != nil {
return fmt.Errorf("failed to uninstall plugin: %w", err)
}
fmt.Printf("Plugin uninstalled successfully: %s\n", plugin.Name)
return nil
}
if plugin == nil {
return fmt.Errorf("plugin not found: %s", idOrName)
fmt.Printf("Uninstalling plugin: %s\n", idOrName)
if err := manager.UninstallByIDOrName(idOrName); err != nil {
return err
}
fmt.Printf("Plugin uninstalled successfully: %s\n", idOrName)
return nil
}
installed, err := manager.IsInstalled(*plugin)
func updatePluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
return fmt.Errorf("failed to create manager: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
fmt.Printf("Uninstalling plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Uninstall(*plugin); err != nil {
return fmt.Errorf("failed to uninstall plugin: %w", err)
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(idOrName, pluginList)
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Update(*plugin); err != nil {
return fmt.Errorf("failed to update plugin: %w", err)
}
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
return nil
}
fmt.Printf("Plugin uninstalled successfully: %s\n", plugin.Name)
fmt.Printf("Updating plugin: %s\n", idOrName)
if err := manager.UpdateByIDOrName(idOrName); err != nil {
return err
}
fmt.Printf("Plugin updated successfully: %s\n", idOrName)
return nil
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra"
)
@@ -121,10 +122,10 @@ func updateArchLinux() error {
var helper string
var updateCmd *exec.Cmd
if commandExists("yay") {
if utils.CommandExists("yay") {
helper = "yay"
updateCmd = exec.Command("yay", "-S", packageName)
} else if commandExists("paru") {
} else if utils.CommandExists("paru") {
helper = "paru"
updateCmd = exec.Command("paru", "-S", packageName)
} else {

View File

@@ -10,6 +10,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -448,7 +449,7 @@ func enableGreeter() error {
fmt.Println("Detecting installed compositors...")
compositors := greeter.DetectCompositors()
if commandExists("sway") {
if utils.CommandExists("sway") {
compositors = append(compositors, "sway")
}

View File

@@ -23,7 +23,7 @@ func init() {
updateCmd.AddCommand(updateCheckCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)

View File

@@ -21,7 +21,7 @@ func init() {
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)

View File

@@ -104,7 +104,6 @@ func getAllDMSPIDs() []int {
continue
}
// Check if the child process is still alive
proc, err := os.FindProcess(childPID)
if err != nil {
os.Remove(pidFile)
@@ -112,18 +111,15 @@ func getAllDMSPIDs() []int {
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
// Process is dead, remove stale PID file
os.Remove(pidFile)
continue
}
pids = append(pids, childPID)
// Also get the parent PID from the filename
parentPIDStr := strings.TrimPrefix(entry.Name(), "danklinux-")
parentPIDStr = strings.TrimSuffix(parentPIDStr, ".pid")
if parentPID, err := strconv.Atoi(parentPIDStr); err == nil {
// Check if parent is still alive
if parentProc, err := os.FindProcess(parentPID); err == nil {
if err := parentProc.Signal(syscall.Signal(0)); err == nil {
pids = append(pids, parentPID)
@@ -225,7 +221,6 @@ func runShellInteractive(session bool) {
return
}
// All other signals: clean shutdown
log.Infof("\nReceived signal %v, shutting down...", sig)
cancel()
cmd.Process.Signal(syscall.SIGTERM)
@@ -282,7 +277,6 @@ func restartShell() {
}
func killShell() {
// Get all tracked DMS PIDs from PID files
pids := getAllDMSPIDs()
if len(pids) == 0 {
@@ -293,14 +287,12 @@ func killShell() {
currentPid := os.Getpid()
uniquePids := make(map[int]bool)
// Deduplicate and filter out current process
for _, pid := range pids {
if pid != currentPid {
uniquePids[pid] = true
}
}
// Kill all tracked processes
for pid := range uniquePids {
proc, err := os.FindProcess(pid)
if err != nil {
@@ -308,7 +300,6 @@ func killShell() {
continue
}
// Check if process is still alive before killing
if err := proc.Signal(syscall.Signal(0)); err != nil {
continue
}
@@ -320,7 +311,6 @@ func killShell() {
}
}
// Clean up any remaining PID files
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
@@ -337,7 +327,6 @@ func killShell() {
func runShellDaemon(session bool) {
isSessionManaged = session
// Check if this is the daemon child process by looking for the hidden flag
isDaemonChild := false
for _, arg := range os.Args {
if arg == "--daemon-child" {

View File

@@ -6,12 +6,6 @@ import (
"strings"
)
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
// findCommandPath returns the absolute path to a command in PATH
func findCommandPath(cmd string) (string, error) {
path, err := exec.LookPath(cmd)
if err != nil {

View File

@@ -5,19 +5,14 @@ import (
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
// LocateDMSConfig searches for DMS installation following XDG Base Directory specification
func LocateDMSConfig() (string, error) {
var primaryPaths []string
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
if homeDir, err := os.UserHomeDir(); err == nil {
configHome = filepath.Join(homeDir, ".config")
}
}
configHome := utils.XDGConfigHome()
if configHome != "" {
primaryPaths = append(primaryPaths, filepath.Join(configHome, "quickshell", "dms"))
}

View File

@@ -113,13 +113,14 @@ func RGBToHSV(rgb RGB) HSV {
delta := max - min
var h float64
if delta == 0 {
switch {
case delta == 0:
h = 0
} else if max == rgb.R {
case max == rgb.R:
h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0
} else if max == rgb.G {
case max == rgb.G:
h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0
} else {
default:
h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0
}

View File

@@ -112,31 +112,11 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
}
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if a.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,
}
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
}
func (a *ArchDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
return a.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", a.packageInstalled("accountsservice"))
}
func (a *ArchDistribution) packageInstalled(pkg string) bool {

View File

@@ -76,47 +76,42 @@ func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *
return exec.CommandContext(ctx, "bash", "-c", cmdStr)
}
// Common dependency detection methods
func (b *BaseDistribution) detectGit() deps.Dependency {
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
status := deps.StatusMissing
if b.commandExists("git") {
if b.commandExists(name) {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "git",
Name: name,
Status: status,
Description: "Version control system",
Description: description,
Required: true,
}
}
func (b *BaseDistribution) detectPackage(name, description string, installed bool) deps.Dependency {
status := deps.StatusMissing
if installed {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: name,
Status: status,
Description: description,
Required: true,
}
}
func (b *BaseDistribution) detectGit() deps.Dependency {
return b.detectCommand("git", "Version control system")
}
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,
}
return b.detectCommand("matugen", "Material Design color generation tool")
}
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,
}
return b.detectCommand("dgop", "Desktop portal management tool")
}
func (b *BaseDistribution) detectDMS() deps.Dependency {

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func TestBaseDistribution_detectDMS_NotInstalled(t *testing.T) {
@@ -36,7 +37,7 @@ func TestBaseDistribution_detectDMS_NotInstalled(t *testing.T) {
}
func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
if !commandExists("git") {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
@@ -80,7 +81,7 @@ func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
}
func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
if !commandExists("git") {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
@@ -164,11 +165,6 @@ func TestBaseDistribution_NewBaseDistribution(t *testing.T) {
}
}
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)

View File

@@ -75,45 +75,15 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
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,
}
return d.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", d.packageInstalled("xdg-desktop-portal-gtk"))
}
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,
}
return d.detectCommand("xwayland-satellite", "Xwayland support")
}
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,
}
return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice"))
}
func (d *DebianDistribution) packageInstalled(pkg string) bool {

View File

@@ -97,17 +97,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
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,
}
return f.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", f.packageInstalled("xdg-desktop-portal-gtk"))
}
func (f *FedoraDistribution) packageInstalled(pkg string) bool {

View File

@@ -113,45 +113,15 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
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,
}
return g.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", g.packageInstalled("sys-apps/xdg-desktop-portal-gtk"))
}
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,
}
return g.detectPackage("xwayland-satellite", "Xwayland support", g.packageInstalled("gui-apps/xwayland-satellite"))
}
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,
}
return g.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", g.packageInstalled("sys-apps/accountsservice"))
}
func (g *GentooDistribution) packageInstalled(pkg string) bool {

View File

@@ -87,17 +87,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
}
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,
}
return o.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", o.packageInstalled("xdg-desktop-portal-gtk"))
}
func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {

View File

@@ -85,45 +85,15 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
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,
}
return u.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", u.packageInstalled("xdg-desktop-portal-gtk"))
}
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,
}
return u.detectCommand("xwayland-satellite", "Xwayland support")
}
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,
}
return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice"))
}
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {

View File

@@ -286,6 +286,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, loadInstalledPlugins
}
return m, nil
case pluginUpdatedMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsError = ""
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()

View File

@@ -75,14 +75,13 @@ type MenuItem struct {
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
var dependencies []DependencyInfo
var hyprlandInstalled, niriInstalled bool
if detector != nil {
dependencies = detector.GetInstalledComponents()
hyprlandInstalled, niriInstalled, _ = detector.GetWindowManagerStatus()
}
m := Model{
@@ -201,6 +200,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, loadInstalledPlugins
}
return m, nil
case pluginUpdatedMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsError = ""
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()

View File

@@ -227,6 +227,11 @@ func (m Model) updatePluginInstalledDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd)
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, uninstallPlugin(plugin)
}
case "p":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, updatePlugin(plugin)
}
}
return m, nil
}
@@ -246,6 +251,11 @@ type pluginInstalledMsg struct {
err error
}
type pluginUpdatedMsg struct {
pluginName string
err error
}
func loadInstalledPlugins() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
@@ -337,3 +347,31 @@ func uninstallPlugin(plugin pluginInfo) tea.Cmd {
return pluginUninstalledMsg{pluginName: plugin.Name}
}
}
func updatePlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUpdatedMsg{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.Update(p); err != nil {
return pluginUpdatedMsg{pluginName: plugin.Name, err: err}
}
return pluginUpdatedMsg{pluginName: plugin.Name}
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
// DetectDMSPath checks for DMS installation following XDG Base Directory specification
@@ -22,10 +23,10 @@ func DetectDMSPath() (string, error) {
func DetectCompositors() []string {
var compositors []string
if commandExists("niri") {
if utils.CommandExists("niri") {
compositors = append(compositors, "niri")
}
if commandExists("Hyprland") {
if utils.CommandExists("Hyprland") {
compositors = append(compositors, "Hyprland")
}
@@ -62,7 +63,7 @@ func PromptCompositorChoice(compositors []string) (string, error) {
// EnsureGreetdInstalled checks if greetd is installed and installs it if not
func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
if commandExists("greetd") {
if utils.CommandExists("greetd") {
logFunc("✓ greetd is already installed")
return nil
}
@@ -144,7 +145,7 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
// 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") {
if utils.CommandExists("dms-greeter") {
logFunc("✓ dms-greeter wrapper already installed")
} else {
// Install the wrapper script
@@ -204,7 +205,7 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
// SetupParentDirectoryACLs sets ACLs on parent directories to allow traversal
func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
if !commandExists("setfacl") {
if !utils.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")
@@ -419,7 +420,7 @@ user = "greeter"
// Determine wrapper command path
wrapperCmd := "dms-greeter"
if !commandExists("dms-greeter") {
if !utils.CommandExists("dms-greeter") {
wrapperCmd = "/usr/local/bin/dms-greeter"
}
@@ -486,8 +487,3 @@ func runSudoCmd(sudoPassword string, command string, args ...string) error {
cmd.Stderr = os.Stderr
return cmd.Run()
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}

View File

@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type DiscoveryConfig struct {
@@ -14,13 +16,7 @@ type DiscoveryConfig struct {
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")
}
}
configHome := utils.XDGConfigHome()
if configHome != "" {
searchPaths = append(searchPaths, filepath.Join(configHome, "DankMaterialShell", "cheatsheets"))
}
@@ -43,7 +39,7 @@ func (d *DiscoveryConfig) FindJSONFiles() ([]string, error) {
var files []string
for _, searchPath := range d.SearchPaths {
expandedPath, err := expandPath(searchPath)
expandedPath, err := utils.ExpandPath(searchPath)
if err != nil {
continue
}
@@ -74,20 +70,6 @@ func (d *DiscoveryConfig) FindJSONFiles() ([]string, error) {
return files, nil
}
func expandPath(path string) (string, error) {
expandedPath := os.ExpandEnv(path)
if strings.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

View File

@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func TestDefaultDiscoveryConfig(t *testing.T) {
@@ -272,13 +274,13 @@ func TestExpandPathInDiscovery(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandPath(tt.input)
result, err := utils.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)
t.Errorf("utils.ExpandPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}

View File

@@ -5,6 +5,8 @@ import (
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -42,14 +44,9 @@ func NewHyprlandParser() *HyprlandParser {
}
func (p *HyprlandParser) 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:])
expandedDir, err := utils.ExpandPath(directory)
if err != nil {
return err
}
info, err := os.Stat(expandedDir)

View File

@@ -5,9 +5,9 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type JSONFileProvider struct {
@@ -20,7 +20,7 @@ func NewJSONFileProvider(filePath string) (*JSONFileProvider, error) {
return nil, fmt.Errorf("file path cannot be empty")
}
expandedPath, err := expandPath(filePath)
expandedPath, err := utils.ExpandPath(filePath)
if err != nil {
return nil, fmt.Errorf("failed to expand path: %w", err)
}
@@ -117,17 +117,3 @@ func (j *JSONFileProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
Binds: categorizedBinds,
}, nil
}
func expandPath(path string) (string, error) {
expandedPath := os.ExpandEnv(path)
if strings.HasPrefix(expandedPath, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
expandedPath = filepath.Join(home, expandedPath[1:])
}
return filepath.Clean(expandedPath), nil
}

View File

@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func TestNewJSONFileProvider(t *testing.T) {
@@ -266,13 +268,13 @@ func TestExpandPath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandPath(tt.input)
result, err := utils.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)
t.Errorf("utils.ExpandPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}

View File

@@ -5,6 +5,8 @@ import (
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -34,14 +36,9 @@ func NewMangoWCParser() *MangoWCParser {
}
func (p *MangoWCParser) 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:])
expandedPath, err := utils.ExpandPath(path)
if err != nil {
return err
}
info, err := os.Stat(expandedPath)

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document"
)
@@ -29,15 +30,7 @@ func NewNiriProvider(configDir string) *NiriProvider {
}
func defaultNiriConfigDir() string {
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
return filepath.Join(configHome, "niri")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "niri")
return filepath.Join(utils.XDGConfigHome(), "niri")
}
func (n *NiriProvider) Name() string {

View File

@@ -5,6 +5,8 @@ import (
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -42,14 +44,9 @@ func NewSwayParser() *SwayParser {
}
func (p *SwayParser) 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:])
expandedPath, err := utils.ExpandPath(path)
if err != nil {
return err
}
info, err := os.Stat(expandedPath)

View File

@@ -13,6 +13,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
var (
@@ -277,7 +278,7 @@ func appendConfig(opts *Options, cfgFile *os.File, checkCmd, fileName string) {
if _, err := os.Stat(configPath); err != nil {
return
}
if checkCmd != "skip" && !commandExists(checkCmd) {
if checkCmd != "skip" && !utils.CommandExists(checkCmd) {
return
}
data, err := os.ReadFile(configPath)
@@ -293,7 +294,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir, checkCmd, fil
if _, err := os.Stat(configPath); err != nil {
return
}
if checkCmd != "skip" && !commandExists(checkCmd) {
if checkCmd != "skip" && !utils.CommandExists(checkCmd) {
return
}
data, err := os.ReadFile(configPath)
@@ -390,11 +391,6 @@ func extractTOMLSection(content, startMarker, endMarker string) string {
return content[startIdx : startIdx+endIdx]
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func checkMatugenVersion() {
matugenVersionOnce.Do(func() {
cmd := exec.Command("matugen", "--version")

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/afero"
)
@@ -32,33 +33,70 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
}
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")
return filepath.Join(utils.XDGConfigHome(), "DankMaterialShell", "plugins")
}
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
path, err := m.findInstalledPath(plugin.ID)
if err != nil {
return false, err
}
if exists {
return true, nil
return path != "", nil
}
func (m *Manager) findInstalledPath(pluginID string) (string, error) {
// Check user plugins directory
path, err := m.findInDir(m.pluginsDir, pluginID)
if err != nil {
return "", err
}
if path != "" {
return path, nil
}
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return false, err
// Check system plugins directory
systemDir := "/etc/xdg/quickshell/dms-plugins"
return m.findInDir(systemDir, pluginID)
}
func (m *Manager) findInDir(dir, pluginID string) (string, error) {
// First, check if folder with exact ID name exists
exactPath := filepath.Join(dir, pluginID)
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
return exactPath, nil
}
return systemExists, nil
// Scan all folders and check plugin.json for matching ID
exists, err := afero.DirExists(m.fs, dir)
if err != nil || !exists {
return "", nil
}
entries, err := afero.ReadDir(m.fs, dir)
if err != nil {
return "", nil
}
for _, entry := range entries {
name := entry.Name()
if name == ".repos" || strings.HasSuffix(name, ".meta") {
continue
}
fullPath := filepath.Join(dir, name)
isPlugin := entry.IsDir() || entry.Mode()&os.ModeSymlink != 0
if !isPlugin {
if info, err := m.fs.Stat(fullPath); err == nil && info.IsDir() {
isPlugin = true
}
}
if isPlugin && m.getPluginID(fullPath) == pluginID {
return fullPath, nil
}
}
return "", nil
}
func (m *Manager) Install(plugin Plugin) error {
@@ -151,25 +189,19 @@ func (m *Manager) createSymlink(source, dest string) error {
}
func (m *Manager) Update(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
pluginPath, err := m.findInstalledPath(plugin.ID)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
return fmt.Errorf("failed to find plugin: %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)
}
if pluginPath == "" {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
@@ -209,25 +241,19 @@ func (m *Manager) Update(plugin Plugin) error {
}
func (m *Manager) Uninstall(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
pluginPath, err := m.findInstalledPath(plugin.ID)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
return fmt.Errorf("failed to find plugin: %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)
}
if pluginPath == "" {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
@@ -369,47 +395,174 @@ func (m *Manager) ListInstalled() ([]string, error) {
// getPluginID reads the plugin.json file and returns the plugin ID
func (m *Manager) getPluginID(pluginPath string) string {
manifest := m.getPluginManifest(pluginPath)
if manifest == nil {
return ""
}
return manifest.ID
}
func (m *Manager) getPluginManifest(pluginPath string) *pluginManifest {
manifestPath := filepath.Join(pluginPath, "plugin.json")
data, err := afero.ReadFile(m.fs, manifestPath)
if err != nil {
return ""
return nil
}
var manifest struct {
ID string `json:"id"`
}
var manifest pluginManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return ""
return nil
}
return manifest.ID
return &manifest
}
type pluginManifest struct {
ID string `json:"id"`
Name string `json:"name"`
}
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)
func (m *Manager) UninstallByIDOrName(idOrName string) error {
pluginPath, err := m.findInstalledPathByIDOrName(idOrName)
if err != nil {
return false, fmt.Errorf("failed to check if plugin exists: %w", err)
return err
}
if pluginPath == "" {
return fmt.Errorf("plugin not found: %s", idOrName)
}
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 strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot uninstall system plugin: %s", idOrName)
}
metaPath := pluginPath + ".meta"
metaExists, _ := afero.Exists(m.fs, metaPath)
if metaExists {
if err := m.fs.Remove(pluginPath); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
if systemExists {
return false, nil
if err := m.fs.Remove(metaPath); err != nil {
return fmt.Errorf("failed to remove metadata: %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) UpdateByIDOrName(idOrName string) error {
pluginPath, err := m.findInstalledPathByIDOrName(idOrName)
if err != nil {
return err
}
if pluginPath == "" {
return fmt.Errorf("plugin not found: %s", idOrName)
}
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot update system plugin: %s", idOrName)
}
metaPath := pluginPath + ".meta"
metaExists, _ := afero.Exists(m.fs, metaPath)
if metaExists {
// Plugin is from monorepo, but we don't know the repo URL without registry
// Just try to pull from existing .git in the symlink target
return fmt.Errorf("cannot update monorepo plugin without registry info: %s", idOrName)
}
// Standalone plugin - just pull
if err := m.gitClient.Pull(pluginPath); err != nil {
return fmt.Errorf("failed to update plugin: %w", err)
}
return nil
}
func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
path, err := m.findInDirByIDOrName(m.pluginsDir, idOrName)
if err != nil {
return "", err
}
if path != "" {
return path, nil
}
systemDir := "/etc/xdg/quickshell/dms-plugins"
return m.findInDirByIDOrName(systemDir, idOrName)
}
func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
// Check exact folder name match first
exactPath := filepath.Join(dir, idOrName)
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
return exactPath, nil
}
exists, err := afero.DirExists(m.fs, dir)
if err != nil || !exists {
return "", nil
}
entries, err := afero.ReadDir(m.fs, dir)
if err != nil {
return "", nil
}
for _, entry := range entries {
name := entry.Name()
if name == ".repos" || strings.HasSuffix(name, ".meta") {
continue
}
fullPath := filepath.Join(dir, name)
isPlugin := entry.IsDir() || entry.Mode()&os.ModeSymlink != 0
if !isPlugin {
if info, err := m.fs.Stat(fullPath); err == nil && info.IsDir() {
isPlugin = true
}
}
if !isPlugin {
continue
}
manifest := m.getPluginManifest(fullPath)
if manifest == nil {
continue
}
if manifest.ID == idOrName || manifest.Name == idOrName {
return fullPath, nil
}
}
return "", nil
}
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) {
pluginPath, err := m.findInstalledPath(pluginID)
if err != nil {
return false, fmt.Errorf("failed to find plugin: %w", err)
}
if pluginPath == "" {
return false, fmt.Errorf("plugin not installed: %s", pluginID)
}
// Check if there's a .meta file (plugin installed from a monorepo)
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return false, nil
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {

View File

@@ -3,6 +3,8 @@ package plugins
import (
"sort"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func FuzzySearch(query string, plugins []Plugin) []Plugin {
@@ -11,18 +13,12 @@ func FuzzySearch(query string, plugins []Plugin) []Plugin {
}
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
return utils.Filter(plugins, func(p Plugin) bool {
return fuzzyMatch(queryLower, strings.ToLower(p.Name)) ||
fuzzyMatch(queryLower, strings.ToLower(p.Category)) ||
fuzzyMatch(queryLower, strings.ToLower(p.Description)) ||
fuzzyMatch(queryLower, strings.ToLower(p.Author))
})
}
func fuzzyMatch(query, text string) bool {
@@ -39,57 +35,34 @@ 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
return utils.Filter(plugins, func(p Plugin) bool {
return strings.ToLower(p.Category) == categoryLower
})
}
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
return utils.Filter(plugins, func(p Plugin) bool {
return utils.Any(p.Compositors, func(c string) bool {
return strings.ToLower(c) == compositorLower
})
})
}
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
return utils.Filter(plugins, func(p Plugin) bool {
return utils.Any(p.Capabilities, func(c string) bool {
return strings.ToLower(c) == capabilityLower
})
})
}
func SortByFirstParty(plugins []Plugin) []Plugin {
@@ -103,3 +76,13 @@ func SortByFirstParty(plugins []Plugin) []Plugin {
})
return plugins
}
func FindByIDOrName(idOrName string, plugins []Plugin) *Plugin {
if p, found := utils.Find(plugins, func(p Plugin) bool { return p.ID == idOrName }); found {
return &p
}
if p, found := utils.Find(plugins, func(p Plugin) bool { return p.Name == idOrName }); found {
return &p
}
return nil
}

View File

@@ -9,7 +9,10 @@ import (
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func BufferToImage(buf *ShmBuffer) *image.RGBA {
@@ -116,70 +119,30 @@ func GetOutputDir() string {
}
func getXDGPicturesDir() string {
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
home := os.Getenv("HOME")
if home == "" {
return ""
}
configDir = filepath.Join(home, ".config")
}
userDirsFile := filepath.Join(configDir, "user-dirs.dirs")
userDirsFile := filepath.Join(utils.XDGConfigHome(), "user-dirs.dirs")
data, err := os.ReadFile(userDirsFile)
if err != nil {
return ""
}
for _, line := range splitLines(string(data)) {
for _, line := range strings.Split(string(data), "\n") {
if len(line) == 0 || line[0] == '#' {
continue
}
const prefix = "XDG_PICTURES_DIR="
if len(line) > len(prefix) && line[:len(prefix)] == prefix {
path := line[len(prefix):]
path = trimQuotes(path)
path = expandHome(path)
return path
if !strings.HasPrefix(line, prefix) {
continue
}
path := strings.Trim(line[len(prefix):], "\"")
expanded, err := utils.ExpandPath(path)
if err != nil {
return ""
}
return expanded
}
return ""
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func trimQuotes(s string) string {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
func expandHome(path string) string {
if len(path) >= 5 && path[:5] == "$HOME" {
home := os.Getenv("HOME")
return home + path[5:]
}
if len(path) >= 1 && path[0] == '~' {
home := os.Getenv("HOME")
return home + path[1:]
}
return path
}
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
}

View File

@@ -6,6 +6,8 @@ import (
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type ThemeColors struct {
@@ -72,15 +74,7 @@ func loadColorsFile() *ColorScheme {
}
func getColorsFilePath() string {
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
home := os.Getenv("HOME")
if home == "" {
return ""
}
cacheDir = filepath.Join(home, ".cache")
}
return filepath.Join(cacheDir, "DankMaterialShell", "dms-colors.json")
return filepath.Join(utils.XDGCacheHome(), "DankMaterialShell", "dms-colors.json")
}
func isLightMode() bool {

View File

@@ -15,50 +15,47 @@ func HandleUninstall(conn net.Conn, req models.Request) {
return
}
registry, err := plugins.NewRegistry()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
return
}
pluginList, err := registry.List()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
return
}
var plugin *plugins.Plugin
for _, p := range pluginList {
if p.Name == name {
plugin = &p
break
}
}
if plugin == nil {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
return
}
manager, err := plugins.NewManager()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
return
}
installed, err := manager.IsInstalled(*plugin)
// First try to find in registry (by name or ID)
registry, err := plugins.NewRegistry()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
return
}
if !installed {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(name, pluginList)
// If found in registry, use that
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
return
}
if !installed {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
return
}
if err := manager.Uninstall(*plugin); err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall plugin: %v", err))
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("plugin uninstalled: %s", plugin.Name),
})
return
}
if err := manager.Uninstall(*plugin); err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall plugin: %v", err))
// Not in registry - try to find and uninstall from installed plugins directly
if err := manager.UninstallByIDOrName(name); err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
return
}

View File

@@ -15,50 +15,45 @@ func HandleUpdate(conn net.Conn, req models.Request) {
return
}
registry, err := plugins.NewRegistry()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
return
}
pluginList, err := registry.List()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
return
}
var plugin *plugins.Plugin
for _, p := range pluginList {
if p.Name == name {
plugin = &p
break
}
}
if plugin == nil {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
return
}
manager, err := plugins.NewManager()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
return
}
installed, err := manager.IsInstalled(*plugin)
registry, err := plugins.NewRegistry()
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
return
}
if !installed {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(name, pluginList)
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
return
}
if !installed {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
return
}
if err := manager.Update(*plugin); err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to update plugin: %v", err))
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("plugin updated: %s", plugin.Name),
})
return
}
if err := manager.Update(*plugin); err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("failed to update plugin: %v", err))
// Not in registry - try to update from installed plugins directly
if err := manager.UpdateByIDOrName(name); err != nil {
models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
return
}

View File

@@ -0,0 +1,8 @@
package utils
import "os/exec"
func CommandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}

View File

@@ -0,0 +1,52 @@
package utils
import (
"os"
"path/filepath"
"strings"
)
func ExpandPath(path string) (string, error) {
expanded := os.ExpandEnv(path)
expanded = filepath.Clean(expanded)
if strings.HasPrefix(expanded, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
expanded = filepath.Join(home, expanded[1:])
}
return expanded, nil
}
func XDGConfigHome() string {
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
return configHome
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config")
}
return filepath.Join(os.TempDir(), ".config")
}
func XDGCacheHome() string {
if cacheHome := os.Getenv("XDG_CACHE_HOME"); cacheHome != "" {
return cacheHome
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".cache")
}
return filepath.Join(os.TempDir(), ".cache")
}
func XDGDataHome() string {
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
return dataHome
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".local", "share")
}
return filepath.Join(os.TempDir(), ".local", "share")
}

View File

@@ -0,0 +1,106 @@
package utils
import (
"os"
"path/filepath"
"testing"
)
func TestExpandPathTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home directory")
}
result, err := ExpandPath("~/test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := filepath.Join(home, "test")
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestExpandPathEnvVar(t *testing.T) {
t.Setenv("TEST_PATH_VAR", "/custom/path")
result, err := ExpandPath("$TEST_PATH_VAR/subdir")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "/custom/path/subdir" {
t.Errorf("expected /custom/path/subdir, got %s", result)
}
}
func TestExpandPathAbsolute(t *testing.T) {
result, err := ExpandPath("/absolute/path")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "/absolute/path" {
t.Errorf("expected /absolute/path, got %s", result)
}
}
func TestXDGConfigHomeDefault(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "")
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home directory")
}
result := XDGConfigHome()
expected := filepath.Join(home, ".config")
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestXDGConfigHomeCustom(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "/custom/config")
result := XDGConfigHome()
if result != "/custom/config" {
t.Errorf("expected /custom/config, got %s", result)
}
}
func TestXDGCacheHomeDefault(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", "")
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home directory")
}
result := XDGCacheHome()
expected := filepath.Join(home, ".cache")
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestXDGCacheHomeCustom(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", "/custom/cache")
result := XDGCacheHome()
if result != "/custom/cache" {
t.Errorf("expected /custom/cache, got %s", result)
}
}
func TestXDGDataHomeDefault(t *testing.T) {
t.Setenv("XDG_DATA_HOME", "")
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home directory")
}
result := XDGDataHome()
expected := filepath.Join(home, ".local", "share")
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestXDGDataHomeCustom(t *testing.T) {
t.Setenv("XDG_DATA_HOME", "/custom/data")
result := XDGDataHome()
if result != "/custom/data" {
t.Errorf("expected /custom/data, got %s", result)
}
}

View File

@@ -0,0 +1,56 @@
package utils
func Filter[T any](items []T, predicate func(T) bool) []T {
var result []T
for _, item := range items {
if predicate(item) {
result = append(result, item)
}
}
return result
}
func Find[T any](items []T, predicate func(T) bool) (T, bool) {
for _, item := range items {
if predicate(item) {
return item, true
}
}
var zero T
return zero, false
}
func Map[T, U any](items []T, transform func(T) U) []U {
result := make([]U, len(items))
for i, item := range items {
result[i] = transform(item)
}
return result
}
func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
func Any[T any](items []T, predicate func(T) bool) bool {
for _, item := range items {
if predicate(item) {
return true
}
}
return false
}
func All[T any](items []T, predicate func(T) bool) bool {
for _, item := range items {
if !predicate(item) {
return false
}
}
return true
}

View File

@@ -0,0 +1,72 @@
package utils
import (
"testing"
)
func TestFilter(t *testing.T) {
nums := []int{1, 2, 3, 4, 5}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
if len(evens) != 2 || evens[0] != 2 || evens[1] != 4 {
t.Errorf("expected [2, 4], got %v", evens)
}
}
func TestFilterEmpty(t *testing.T) {
result := Filter([]int{1, 2, 3}, func(n int) bool { return n > 10 })
if len(result) != 0 {
t.Errorf("expected empty slice, got %v", result)
}
}
func TestFind(t *testing.T) {
nums := []int{1, 2, 3, 4, 5}
val, found := Find(nums, func(n int) bool { return n == 3 })
if !found || val != 3 {
t.Errorf("expected 3, got %v (found=%v)", val, found)
}
}
func TestFindNotFound(t *testing.T) {
nums := []int{1, 2, 3}
val, found := Find(nums, func(n int) bool { return n == 99 })
if found || val != 0 {
t.Errorf("expected zero value not found, got %v (found=%v)", val, found)
}
}
func TestMap(t *testing.T) {
nums := []int{1, 2, 3}
doubled := Map(nums, func(n int) int { return n * 2 })
if len(doubled) != 3 || doubled[0] != 2 || doubled[1] != 4 || doubled[2] != 6 {
t.Errorf("expected [2, 4, 6], got %v", doubled)
}
}
func TestMapTypeConversion(t *testing.T) {
nums := []int{1, 2, 3}
strs := Map(nums, func(n int) string { return string(rune('a' + n - 1)) })
if strs[0] != "a" || strs[1] != "b" || strs[2] != "c" {
t.Errorf("expected [a, b, c], got %v", strs)
}
}
func TestContains(t *testing.T) {
nums := []int{1, 2, 3}
if !Contains(nums, 2) {
t.Error("expected to contain 2")
}
if Contains(nums, 99) {
t.Error("expected not to contain 99")
}
}
func TestAny(t *testing.T) {
nums := []int{1, 2, 3, 4, 5}
if !Any(nums, func(n int) bool { return n > 4 }) {
t.Error("expected any > 4")
}
if Any(nums, func(n int) bool { return n > 10 }) {
t.Error("expected none > 10")
}
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
mocks_version "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/version"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func TestCompareVersions(t *testing.T) {
@@ -150,7 +151,7 @@ func TestGetCurrentDMSVersion_NotInstalled(t *testing.T) {
}
func TestGetCurrentDMSVersion_GitTag(t *testing.T) {
if !commandExists("git") {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
@@ -183,7 +184,7 @@ func TestGetCurrentDMSVersion_GitTag(t *testing.T) {
}
func TestGetCurrentDMSVersion_GitBranch(t *testing.T) {
if !commandExists("git") {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
@@ -314,11 +315,6 @@ func TestVersionInfo_HasUpdate_Tag(t *testing.T) {
}
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func TestGetLatestDMSVersion_FallbackParsing(t *testing.T) {
jsonResponse := `{
"tag_name": "v0.1.17",

View File

@@ -890,17 +890,51 @@ Item {
id: rowLayout
Row {
spacing: 4
visible: loadedIcons.length > 0 || SettingsData.showWorkspaceIndex
StyledText {
topPadding: 2
rightPadding: isActive ? 4 : 0
visible: SettingsData.showWorkspaceIndex
text: {
return root.getWorkspaceIndex(modelData);
visible: loadedIcons.length > 0 || SettingsData.showWorkspaceIndex || loadedHasIcon
Item {
visible: loadedHasIcon && loadedIconData?.type === "icon"
width: wsIcon.width + (isActive && loadedIcons.length > 0 ? 4 : 0)
height: 18
DankIcon {
id: wsIcon
anchors.verticalCenter: parent.verticalCenter
name: loadedIconData?.value ?? ""
size: Theme.barTextSize(barThickness, barConfig?.fontScale)
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
weight: (isActive && !isPlaceholder) ? 500 : 400
}
}
Item {
visible: loadedHasIcon && loadedIconData?.type === "text"
width: wsText.implicitWidth + (isActive && loadedIcons.length > 0 ? 4 : 0)
height: 18
StyledText {
id: wsText
anchors.verticalCenter: parent.verticalCenter
text: loadedIconData?.value ?? ""
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
font.pixelSize: Theme.barTextSize(barThickness, barConfig?.fontScale)
font.weight: (isActive && !isPlaceholder) ? Font.DemiBold : Font.Normal
}
}
Item {
visible: SettingsData.showWorkspaceIndex && !loadedHasIcon
width: wsIndexText.implicitWidth + (isActive && loadedIcons.length > 0 ? 4 : 0)
height: 18
StyledText {
id: wsIndexText
anchors.verticalCenter: parent.verticalCenter
text: root.getWorkspaceIndex(modelData)
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
font.pixelSize: Theme.barTextSize(barThickness, barConfig?.fontScale)
font.weight: (isActive && !isPlaceholder) ? Font.DemiBold : Font.Normal
}
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
font.pixelSize: Theme.barTextSize(barThickness, barConfig?.fontScale)
font.weight: (isActive && !isPlaceholder) ? Font.DemiBold : Font.Normal
}
Repeater {
@@ -973,7 +1007,25 @@ Item {
id: columnLayout
Column {
spacing: 4
visible: loadedIcons.length > 0
visible: loadedIcons.length > 0 || loadedHasIcon
DankIcon {
visible: loadedHasIcon && loadedIconData?.type === "icon"
anchors.horizontalCenter: parent.horizontalCenter
name: loadedIconData?.value ?? ""
size: Theme.barTextSize(barThickness, barConfig?.fontScale)
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
weight: (isActive && !isPlaceholder) ? 500 : 400
}
StyledText {
visible: loadedHasIcon && loadedIconData?.type === "text"
anchors.horizontalCenter: parent.horizontalCenter
text: loadedIconData?.value ?? ""
color: (isActive || isUrgent) ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : isPlaceholder ? Theme.surfaceTextAlpha : Theme.surfaceTextMedium
font.pixelSize: Theme.barTextSize(barThickness, barConfig?.fontScale)
font.weight: (isActive && !isPlaceholder) ? Font.DemiBold : Font.Normal
}
Repeater {
model: ScriptModel {