1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-02 02:22:06 -04:00

Compare commits

...

15 Commits

Author SHA1 Message Date
Amaan Qureshi
eb5afcdc40 flake: let module callers supply pkgs so overlays reach the build (#2244)
The nixosModule/homeModule path previously called `buildDmsPkgs pkgs` but
internally referenced `self.packages.${system}.default`, which was
instantiated via `nixpkgs.legacyPackages`, an unoverlayed pkgs. That
meant downstream flakes couldn't reach through their own overlays to
the dms-shell build (e.g. to swap `kdePackages.sonnet` or trim perl
out of the aspell closure).

Extract the derivation as `mkDmsShell = pkgs: ...` at the top-level
`let`, and call it from both `packages.${system}.dms-shell` (for
direct consumers of the flake) and `buildDmsPkgs pkgs` (for module
consumers, which now pass in the system's overlayed pkgs).

Also re-checks overrideAttrs / .override still work: `mkDmsShell pkgs`
is the same `pkgs.lib.makeOverridable` wrapper as before, just
parameterized on the caller's pkgs instance.

Co-authored-by: Lucas <43530291+LuckShiba@users.noreply.github.com>
2026-04-28 22:59:59 -04:00
Youseffo13
dd2a3f3efc Fixed mux tab having same id as locale tab (#2031)
* Feat: fix mux tab having same id as locale tab

* Feat: updated some icon

* Update KeybindsModal.qml
2026-04-28 16:34:31 -04:00
Walid Salah
161fa2dba5 Add terminal multiplexer launcher (#1687)
* Add tmux

* Add mux modal

* Restore the settings config version

* Revert typo

* Use DankModal for InputModal

* Simplify terminal flags

* use showWithOptions for inputModals instead

* Fix translation

* use Quickshell.env("TERMINAL") to choose terminal

* Fix typo

* Hide muxModal after creating new session

* Add mux check, moved exclusion to service, And use ScriptModel

* Revert unrelated change

* Add blank line
2026-04-28 16:34:29 -04:00
mihem
f5f50f7add feat(running-apps): stronger active app highlight + indicator bar (#2190)
The focused app background used 20% primary opacity which was barely
visible. Increase to 45% to make the active window unambiguous at a glance.
2026-04-28 16:26:33 -04:00
bbedward
372cf2f566 core: add privesc package for privilege escalation
- Adds support for run0 and doas
fixes #998
2026-04-28 16:03:24 -04:00
bbedward
b70acbc283 revert greeter clock keys 2026-04-28 15:05:55 -04:00
purian23
571c1158bf (dms): Add input group to dms setup
- Suppress fix/warnings
2026-04-28 14:34:24 -04:00
bbedward
ac03a2e4b2 niri overlay: fix state binding
fixes #2301
2026-04-28 13:19:57 -04:00
Nic Ficca
07460dc3b7 Fix: close notification center after clicking action buttons (#2276)
* Close notification center after clicking action buttons

When clicking action buttons (e.g., "View", "Activate") in the
notification center, the action fires but the popout stays open. Since
the center is a layer-shell surface, it blocks focus changes on Wayland
compositors like niri, making the action appear to do nothing.

The keyboard navigation path already closes the center after invoking
actions; this brings the mouse click path in line.

Also fix closeNotificationCenter() in PopoutService to set
notificationHistoryVisible = false (matching PopoutManager._closePopout)
instead of calling close() directly, which left the visibility property
stale and caused the bell toggle to require two presses to reopen.

Fixes #2178

* Sync notificationHistoryVisible with shouldBeVisible

NotificationCenterPopout has its own notificationHistoryVisible property
that drives open/close, but the PopoutService public API (open, close,
toggle) calls DankPopout methods directly, bypassing that property. This
leaves notificationHistoryVisible stale, causing the bell toggle to
require two presses to reopen after a programmatic close.

Sync the property from onShouldBeVisibleChanged so any caller going
through open()/close() gets the state corrected automatically.
2026-04-28 11:50:40 -04:00
Kangheng Liu
7412fee590 feat(desktop): expose accept keyboard focus to desktop widgets (#2285)
Opt in by setting acceptsKeyboardFocus: true
2026-04-28 11:46:43 -04:00
bbedward
85c2954958 osd(media): workaround for firefox reporting youtube thumbnails as
players
fixes #2298
2026-04-28 11:46:26 -04:00
bbedward
7c9e805cbe port audioservice fix 2026-04-28 10:25:12 -04:00
bbedward
6926470b04 v 1.4.6 2026-04-28 10:12:56 -04:00
bbedward
713ba1efbb idle/lock: add option to turn off monitors after lock explicitly 2026-04-28 10:12:35 -04:00
bbedward
1919ca7243 fix cherry pick issues 2026-04-28 10:01:09 -04:00
54 changed files with 9901 additions and 4760 deletions

View File

@@ -4,6 +4,7 @@ package main
import ( import (
"bufio" "bufio"
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -15,6 +16,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -130,12 +132,8 @@ func updateArchLinux() error {
return errdefs.ErrUpdateCancelled return errdefs.ErrUpdateCancelled
} }
fmt.Printf("\nRunning: sudo pacman -S %s\n", packageName) fmt.Printf("\nRunning: pacman -S %s\n", packageName)
cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", packageName) if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("Error: Failed to update using pacman: %v\n", err) fmt.Printf("Error: Failed to update using pacman: %v\n", err)
return err return err
} }
@@ -479,11 +477,7 @@ func updateDMSBinary() error {
fmt.Printf("Installing to %s...\n", currentPath) fmt.Printf("Installing to %s...\n", currentPath)
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath) if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
replaceCmd.Stdin = os.Stdin
replaceCmd.Stdout = os.Stdout
replaceCmd.Stderr = os.Stderr
if err := replaceCmd.Run(); err != nil {
return fmt.Errorf("failed to replace binary: %w", err) return fmt.Errorf("failed to replace binary: %w", err)
} }

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -13,6 +14,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/text/cases" "golang.org/x/text/cases"
@@ -29,7 +31,7 @@ var greeterInstallCmd = &cobra.Command{
Use: "install", Use: "install",
Short: "Install and configure DMS greeter", Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface", Long: "Install greetd and configure it to use DMS as the greeter interface",
PreRunE: requireMutableSystemCommand, PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -51,9 +53,10 @@ var greeterInstallCmd = &cobra.Command{
} }
var greeterSyncCmd = &cobra.Command{ var greeterSyncCmd = &cobra.Command{
Use: "sync", Use: "sync",
Short: "Sync DMS theme and settings with greeter", Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth") auth, _ := cmd.Flags().GetBool("auth")
@@ -82,7 +85,7 @@ var greeterEnableCmd = &cobra.Command{
Use: "enable", Use: "enable",
Short: "Enable DMS greeter in greetd config", Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter", Long: "Configure greetd to use DMS as the greeter",
PreRunE: requireMutableSystemCommand, PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -118,7 +121,7 @@ var greeterUninstallCmd = &cobra.Command{
Use: "uninstall", Use: "uninstall",
Short: "Remove DMS greeter configuration and restore previous display manager", Short: "Remove DMS greeter configuration and restore previous display manager",
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state", Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
PreRunE: requireMutableSystemCommand, PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -288,10 +291,7 @@ func uninstallGreeter(nonInteractive bool) error {
} }
fmt.Println("\nDisabling greetd...") fmt.Println("\nDisabling greetd...")
disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd") if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err) fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
} else { } else {
fmt.Println(" ✓ greetd disabled") fmt.Println(" ✓ greetd disabled")
@@ -357,10 +357,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error {
} }
tmp.Close() tmp.Close()
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to restore %s: %w", candidate, err) return fmt.Errorf("failed to restore %s: %w", candidate, err)
} }
if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil {
return err return err
} }
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate) fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
@@ -388,21 +388,14 @@ command = "agreety --cmd /bin/bash"
} }
tmp.Close() tmp.Close()
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to write fallback greetd config: %w", err) return fmt.Errorf("failed to write fallback greetd config: %w", err)
} }
_ = runSudoCommand(sudoPassword, "chmod", "644", configPath) _ = privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath)
fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)") fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)")
return nil return nil
} }
func runSudoCommand(_ string, command string, args ...string) error {
cmd := exec.Command("sudo", append([]string{command}, args...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// suggestDisplayManagerRestore scans for installed DMs and re-enables one // suggestDisplayManagerRestore scans for installed DMs and re-enables one
func suggestDisplayManagerRestore(nonInteractive bool) { func suggestDisplayManagerRestore(nonInteractive bool) {
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
@@ -421,10 +414,7 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
enableDM := func(dm string) { enableDM := func(dm string) {
fmt.Printf(" Enabling %s...\n", dm) fmt.Printf(" Enabling %s...\n", dm)
cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm) if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err) fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
} else { } else {
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm) fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
@@ -623,10 +613,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if response != "n" && response != "no" { if response != "n" && response != "no" {
fmt.Printf("\nAdding user to %s group...\n", greeterGroup) fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username) if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil {
addUserCmd.Stdout = os.Stdout
addUserCmd.Stderr = os.Stderr
if err := addUserCmd.Run(); err != nil {
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
} }
fmt.Printf("✓ User added to %s group\n", greeterGroup) fmt.Printf("✓ User added to %s group\n", greeterGroup)
@@ -846,22 +833,19 @@ func disableDisplayManager(dmName string) (bool, error) {
actionTaken := false actionTaken := false
if state.NeedsDisable { if state.NeedsDisable {
var disableCmd *exec.Cmd var action, actionVerb string
var actionVerb string switch state.EnabledState {
case "static":
if state.EnabledState == "static" {
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName) fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
disableCmd = exec.Command("sudo", "systemctl", "mask", dmName) action = "mask"
actionVerb = "masked" actionVerb = "masked"
} else { default:
fmt.Printf(" Disabling %s...\n", dmName) fmt.Printf(" Disabling %s...\n", dmName)
disableCmd = exec.Command("sudo", "systemctl", "disable", dmName) action = "disable"
actionVerb = "disabled" actionVerb = "disabled"
} }
disableCmd.Stdout = os.Stdout if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil {
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err) return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
} }
@@ -902,10 +886,7 @@ func ensureGreetdEnabled() error {
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
fmt.Println(" Unmasking greetd...") fmt.Println(" Unmasking greetd...")
unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd") if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil {
unmaskCmd.Stdout = os.Stdout
unmaskCmd.Stderr = os.Stderr
if err := unmaskCmd.Run(); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err) return fmt.Errorf("failed to unmask greetd: %w", err)
} }
fmt.Println(" ✓ Unmasked greetd") fmt.Println(" ✓ Unmasked greetd")
@@ -917,10 +898,7 @@ func ensureGreetdEnabled() error {
fmt.Println(" Enabling greetd service...") fmt.Println(" Enabling greetd service...")
} }
enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd") if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil {
enableCmd.Stdout = os.Stdout
enableCmd.Stderr = os.Stderr
if err := enableCmd.Run(); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err) return fmt.Errorf("failed to enable greetd: %w", err)
} }
@@ -950,10 +928,7 @@ func ensureGraphicalTarget() error {
currentTargetStr := strings.TrimSpace(string(currentTarget)) currentTargetStr := strings.TrimSpace(string(currentTarget))
if currentTargetStr != "graphical.target" { if currentTargetStr != "graphical.target" {
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr) fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target") if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil {
setDefaultCmd.Stdout = os.Stdout
setDefaultCmd.Stderr = os.Stderr
if err := setDefaultCmd.Run(); err != nil {
fmt.Println("⚠ Warning: Failed to set graphical.target as default") fmt.Println("⚠ Warning: Failed to set graphical.target as default")
fmt.Println(" Greeter may not start on boot. Run manually:") fmt.Println(" Greeter may not start on boot. Run manually:")
fmt.Println(" sudo systemctl set-default graphical.target") fmt.Println(" sudo systemctl set-default graphical.target")

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -19,7 +21,7 @@ var setupCmd = &cobra.Command{
Use: "setup", Use: "setup",
Short: "Deploy DMS configurations", Short: "Deploy DMS configurations",
Long: "Deploy compositor and terminal configurations with interactive prompts", Long: "Deploy compositor and terminal configurations with interactive prompts",
PersistentPreRunE: requireMutableSystemCommand, PersistentPreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if err := runSetup(); err != nil { if err := runSetup(); err != nil {
log.Fatalf("Error during setup: %v", err) log.Fatalf("Error during setup: %v", err)
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
func runSetup() error { func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===") fmt.Println("=== DMS Configuration Setup ===")
ensureInputGroup()
wm, wmSelected := promptCompositor() wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal() terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd() useSystemd := promptSystemd()
@@ -340,6 +344,37 @@ func runSetup() error {
return nil return nil
} }
// Add user to the input group for the evdev manager for inut state tracking.
// Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() {
if !utils.HasGroup("input") {
return
}
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return
}
out, err := execGroups(currentUser)
if err == nil && strings.Contains(out, "input") {
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
return
}
fmt.Println("Adding user to input group for Caps Lock OSD support...")
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
} else {
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
}
}
func execGroups(user string) (string, error) {
out, err := exec.Command("groups", user).Output()
return string(out), err
}
func promptCompositor() (deps.WindowManager, bool) { func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:") fmt.Println("Select compositor:")
fmt.Println("1) Niri") fmt.Println("1) Niri")

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -269,3 +270,16 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath) return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
} }
// preRunPrivileged combines the immutable-system check with a privesc tool
// selection prompt (shown only when multiple tools are available and the
// $DMS_PRIVESC env var isn't set).
func preRunPrivileged(cmd *cobra.Command, args []string) error {
if err := requireMutableSystemCommand(cmd, args); err != nil {
return err
}
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
return err
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -292,7 +293,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
LogOutput: "Installing base-devel development tools", LogOutput: "Installing base-devel development tools",
} }
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel") cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil { if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err) return fmt.Errorf("failed to install base-devel: %w", err)
} }
@@ -463,7 +464,7 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell", CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
LogOutput: "Removing stable quickshell so quickshell-git can be installed", LogOutput: "Removing stable quickshell so quickshell-git can be installed",
} }
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell") cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil { if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
return fmt.Errorf("failed to remove stable quickshell: %w", err) return fmt.Errorf("failed to remove stable quickshell: %w", err)
} }
@@ -501,7 +502,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60) return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
} }
@@ -779,7 +780,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
installArgs := []string{"pacman", "-U", "--noconfirm"} installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...) installArgs = append(installArgs, files...)
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " ")) installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files)) fileNames := make([]string, len(files))
for i, f := range files { for i, f := range files {

View File

@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
) )
@@ -55,27 +56,6 @@ func (b *BaseDistribution) logError(message string, err error) {
b.log(errorMsg) 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)
}
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency { func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
status := deps.StatusMissing status := deps.StatusMissing
if b.commandExists(name) { if b.commandExists(name) {
@@ -710,7 +690,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
} }
// Install to /usr/local/bin // Install to /usr/local/bin
installCmd := ExecSudoCommand(ctx, sudoPassword, installCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath)) fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install DMS binary: %w", err) return fmt.Errorf("failed to install DMS binary: %w", err)

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -191,7 +192,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists", LogOutput: "Updating APT package lists",
} }
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil { if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err) return fmt.Errorf("failed to update package lists: %w", err)
} }
@@ -208,7 +209,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil { if err := checkCmd.Run(); err != nil {
cmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential") cmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil { if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err) return fmt.Errorf("failed to install build-essential: %w", err)
} }
@@ -224,7 +225,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools", LogOutput: "Installing additional development tools",
} }
devToolsCmd := ExecSudoCommand(ctx, sudoPassword, devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev") "DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil { if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err) return fmt.Errorf("failed to install development tools: %w", err)
@@ -450,7 +451,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName) keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
// Create keyrings directory if it doesn't exist // Create keyrings directory if it doesn't exist
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings") mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
if err := mkdirCmd.Run(); err != nil { if err := mkdirCmd.Run(); err != nil {
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err)) d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
} }
@@ -464,7 +465,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
} }
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath) keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd) cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil { if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err) return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
} }
@@ -480,7 +481,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile), CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
} }
addRepoCmd := ExecSudoCommand(ctx, sudoPassword, addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile)) fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err) return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err)
@@ -500,7 +501,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
CommandInfo: "sudo apt-get update", CommandInfo: "sudo apt-get update",
} }
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err) return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
} }
@@ -546,7 +547,7 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress) return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress)
} }
@@ -634,7 +635,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"} args := []string{"apt-get", "install", "-y"}
args = append(args, depList...) args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82) return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
} }
@@ -652,7 +653,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup", CommandInfo: "sudo apt-get install rustup",
} }
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup") rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err) return fmt.Errorf("failed to install rustup: %w", err)
} }
@@ -691,7 +692,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go", CommandInfo: "sudo apt-get install golang-go",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go") installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90) return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
} }

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -254,7 +255,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"dnf", "install", "-y"} args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...) args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
f.logError("failed to install prerequisites", err) f.logError("failed to install prerequisites", err)
@@ -437,7 +438,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL), CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL)) fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@@ -461,7 +462,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile), CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
} }
priorityCmd := ExecSudoCommand(ctx, sudoPassword, priorityCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile)) fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput() priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil { if err != nil {
@@ -537,7 +538,7 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
} }

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
var GentooGlobalUseFlags = []string{ var GentooGlobalUseFlags = []string{
@@ -201,9 +202,9 @@ func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword
var cmd *exec.Cmd var cmd *exec.Cmd
if hasUse { if hasUse {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags)) cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
} else { } else {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags)) cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
} }
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
@@ -281,7 +282,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Syncing Portage tree with emerge --sync", LogOutput: "Syncing Portage tree with emerge --sync",
} }
syncCmd := ExecSudoCommand(ctx, sudoPassword, "emerge --sync --quiet") syncCmd := privesc.ExecCommand(ctx, sudoPassword, "emerge --sync --quiet")
syncOutput, syncErr := syncCmd.CombinedOutput() syncOutput, syncErr := syncCmd.CombinedOutput()
if syncErr != nil { if syncErr != nil {
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput))) g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
@@ -302,7 +303,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
args := []string{"emerge", "--ask=n", "--quiet"} args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...) args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
g.logError("failed to install prerequisites", err) g.logError("failed to install prerequisites", err)
@@ -503,14 +504,14 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0) return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
} }
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error { func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
packageUseDir := "/etc/portage/package.use" packageUseDir := "/etc/portage/package.use"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", packageUseDir)) fmt.Sprintf("mkdir -p %s", packageUseDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil { if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output))) g.log(fmt.Sprintf("mkdir output: %s", string(output)))
@@ -524,7 +525,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
if checkExistingCmd.Run() == nil { if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName)) g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/") escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword, replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir)) fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
if output, err := replaceCmd.CombinedOutput(); err != nil { if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output))) g.log(fmt.Sprintf("sed delete output: %s", string(output)))
@@ -532,7 +533,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
} }
} }
appendCmd := ExecSudoCommand(ctx, sudoPassword, appendCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir)) fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
output, err := appendCmd.CombinedOutput() output, err := appendCmd.CombinedOutput()
@@ -557,7 +558,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
} }
// Enable GURU repository // Enable GURU repository
enableCmd := ExecSudoCommand(ctx, sudoPassword, enableCmd := privesc.ExecCommand(ctx, sudoPassword,
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code") "eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
output, err := enableCmd.CombinedOutput() output, err := enableCmd.CombinedOutput()
@@ -589,7 +590,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
LogOutput: "Syncing GURU repository", LogOutput: "Syncing GURU repository",
} }
syncCmd := ExecSudoCommand(ctx, sudoPassword, syncCmd := privesc.ExecCommand(ctx, sudoPassword,
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code") "emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput() syncOutput, syncErr := syncCmd.CombinedOutput()
@@ -622,7 +623,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
acceptKeywordsDir := "/etc/portage/package.accept_keywords" acceptKeywordsDir := "/etc/portage/package.accept_keywords"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir)) fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil { if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output))) g.log(fmt.Sprintf("mkdir output: %s", string(output)))
@@ -636,7 +637,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
if checkExistingCmd.Run() == nil { if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName)) g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/") escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword, replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir)) fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
if output, err := replaceCmd.CombinedOutput(); err != nil { if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output))) g.log(fmt.Sprintf("sed delete output: %s", string(output)))
@@ -644,7 +645,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
} }
} }
appendCmd := ExecSudoCommand(ctx, sudoPassword, appendCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir)) fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
output, err := appendCmd.CombinedOutput() output, err := appendCmd.CombinedOutput()
@@ -695,6 +696,6 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0) return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
} }

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
// ManualPackageInstaller provides methods for installing packages from source // ManualPackageInstaller provides methods for installing packages from source
@@ -143,7 +144,7 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s
CommandInfo: "sudo make install", CommandInfo: "sudo make install",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err) m.logError("failed to install dgop", err)
@@ -213,7 +214,7 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
CommandInfo: "dpkg -i niri.deb", CommandInfo: "dpkg -i niri.deb",
} }
installDebCmd := ExecSudoCommand(ctx, sudoPassword, installDebCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir)) fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
output, err := installDebCmd.CombinedOutput() output, err := installDebCmd.CombinedOutput()
@@ -324,7 +325,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
CommandInfo: "sudo cmake --install build", CommandInfo: "sudo cmake --install build",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err) return fmt.Errorf("failed to install quickshell: %w", err)
@@ -387,7 +388,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
CommandInfo: "sudo make install", CommandInfo: "sudo make install",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Hyprland: %w", err) return fmt.Errorf("failed to install Hyprland: %w", err)
@@ -453,7 +454,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/", CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, installCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir)) fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err) return fmt.Errorf("failed to install Ghostty: %w", err)
@@ -492,16 +493,11 @@ func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPasswor
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath), CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
} }
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
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) return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
} }
// Make it executable if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
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) return fmt.Errorf("failed to make matugen executable: %w", err)
} }
@@ -646,15 +642,11 @@ func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, s
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath), CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
} }
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
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) return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
} }
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath) if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err) return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
} }

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -249,7 +250,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas
args := []string{"zypper", "install", "-y"} args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...) args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
o.logError("failed to install prerequisites", err) o.logError("failed to install prerequisites", err)
@@ -485,7 +486,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL), CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("zypper addrepo -f %s", repoURL)) fmt.Sprintf("zypper addrepo -f %s", repoURL))
if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err)) o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err))
@@ -506,7 +507,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
CommandInfo: "sudo zypper --gpg-auto-import-keys refresh", CommandInfo: "sudo zypper --gpg-auto-import-keys refresh",
} }
refreshCmd := ExecSudoCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh") refreshCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to refresh repositories: %w", err) return fmt.Errorf("failed to refresh repositories: %w", err)
} }
@@ -587,7 +588,7 @@ func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sud
} }
for _, alias := range aliases { for _, alias := range aliases {
cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", escapeSingleQuotes(alias))) cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", privesc.EscapeSingleQuotes(alias)))
repoOutput, err := cmd.CombinedOutput() repoOutput, err := cmd.CombinedOutput()
if err != nil { if err != nil {
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput)))) o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
@@ -645,7 +646,7 @@ func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packag
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
} }
@@ -773,7 +774,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
CommandInfo: "sudo cmake --install build", CommandInfo: "sudo cmake --install build",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
installCmd.Dir = tmpDir installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err) return fmt.Errorf("failed to install quickshell: %w", err)
@@ -797,7 +798,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str
CommandInfo: "sudo zypper install rustup", CommandInfo: "sudo zypper install rustup",
} }
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup") rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper install -y rustup")
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err) return fmt.Errorf("failed to install rustup: %w", err)
} }

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -177,7 +178,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Updating APT package lists", LogOutput: "Updating APT package lists",
} }
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil { if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err) return fmt.Errorf("failed to update package lists: %w", err)
} }
@@ -195,7 +196,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil { if err := checkCmd.Run(); err != nil {
// Not installed, install it // Not installed, install it
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential") cmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil { if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err) return fmt.Errorf("failed to install build-essential: %w", err)
} }
@@ -211,7 +212,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
LogOutput: "Installing additional development tools", LogOutput: "Installing additional development tools",
} }
devToolsCmd := ExecSudoCommand(ctx, sudoPassword, devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev") "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 { if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err) return fmt.Errorf("failed to install development tools: %w", err)
@@ -398,7 +399,7 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool) enabledRepos := make(map[string]bool)
installPPACmd := ExecSudoCommand(ctx, sudoPassword, installPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"apt-get install -y software-properties-common") "apt-get install -y software-properties-common")
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil { if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
return fmt.Errorf("failed to install software-properties-common: %w", err) return fmt.Errorf("failed to install software-properties-common: %w", err)
@@ -416,7 +417,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL), CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, cmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL)) fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { 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) u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
@@ -437,7 +438,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
CommandInfo: "sudo apt-get update", CommandInfo: "sudo apt-get update",
} }
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { 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 fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
} }
@@ -504,7 +505,7 @@ func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []st
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
} }
@@ -591,7 +592,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
args := []string{"apt-get", "install", "-y"} args := []string{"apt-get", "install", "-y"}
args = append(args, depList...) args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82) return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
} }
@@ -609,7 +610,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
CommandInfo: "sudo apt-get install rustup", CommandInfo: "sudo apt-get install rustup",
} }
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup") rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err) return fmt.Errorf("failed to install rustup: %w", err)
} }
@@ -649,7 +650,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports", CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
} }
addPPACmd := ExecSudoCommand(ctx, sudoPassword, addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"add-apt-repository -y ppa:longsleep/golang-backports") "add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil { if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err) return fmt.Errorf("failed to add Go PPA: %w", err)
@@ -664,7 +665,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get update", CommandInfo: "sudo apt-get update",
} }
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil { 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) return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
} }
@@ -678,7 +679,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
CommandInfo: "sudo apt-get install golang-go", CommandInfo: "sudo apt-get install golang-go",
} }
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go") installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90) return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
} }

View File

@@ -16,6 +16,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document" "github.com/sblinch/kdl-go/document"
@@ -345,56 +346,17 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
switch config.Family { switch config.Family {
case distros.FamilyArch: case distros.FamilyArch:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"pacman -S --needed --noconfirm greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd")
}
case distros.FamilyFedora: case distros.FamilyFedora:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"dnf install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd")
}
case distros.FamilySUSE: case distros.FamilySUSE:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, case distros.FamilyUbuntu, distros.FamilyDebian:
"zypper install -y greetd") installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get 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.FamilyGentoo: case distros.FamilyGentoo:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"emerge --ask n sys-apps/greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd")
}
case distros.FamilyNix: case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix") return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
default: default:
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family) return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
} }
@@ -455,56 +417,56 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug)) logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug))
if _, err := exec.LookPath("gpg"); err != nil { if _, err := exec.LookPath("gpg"); err != nil {
logFunc("Installing gnupg for OBS repository key import...") logFunc("Installing gnupg for OBS repository key import...")
installGPGCmd := exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "gnupg") installGPGCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y gnupg")
installGPGCmd.Stdout = os.Stdout installGPGCmd.Stdout = os.Stdout
installGPGCmd.Stderr = os.Stderr installGPGCmd.Stderr = os.Stderr
if err := installGPGCmd.Run(); err != nil { if err := installGPGCmd.Run(); err != nil {
logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err)) logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err))
} }
} }
mkdirCmd := exec.CommandContext(ctx, "sudo", "mkdir", "-p", "/etc/apt/keyrings") mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
mkdirCmd.Stdout = os.Stdout mkdirCmd.Stdout = os.Stdout
mkdirCmd.Stderr = os.Stderr mkdirCmd.Stderr = os.Stderr
mkdirCmd.Run() mkdirCmd.Run()
addKeyCmd := exec.CommandContext(ctx, "bash", "-c", addKeyCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL)) fmt.Sprintf(`bash -c "curl -fsSL %s | gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg"`, keyURL))
addKeyCmd.Stdout = os.Stdout addKeyCmd.Stdout = os.Stdout
addKeyCmd.Stderr = os.Stderr addKeyCmd.Stderr = os.Stderr
addKeyCmd.Run() addKeyCmd.Run()
addRepoCmd := exec.CommandContext(ctx, "bash", "-c", addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine)) fmt.Sprintf(`bash -c "echo '%s' > /etc/apt/sources.list.d/danklinux.list"`, repoLine))
addRepoCmd.Stdout = os.Stdout addRepoCmd.Stdout = os.Stdout
addRepoCmd.Stderr = os.Stderr addRepoCmd.Stderr = os.Stderr
addRepoCmd.Run() addRepoCmd.Run()
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
case distros.FamilySUSE: case distros.FamilySUSE:
repoURL := getOpenSUSEOBSRepoURL(osInfo) repoURL := getOpenSUSEOBSRepoURL(osInfo)
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL) failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL)
logFunc("Adding DankLinux OBS repository...") logFunc("Adding DankLinux OBS repository...")
addRepoCmd := exec.CommandContext(ctx, "sudo", "zypper", "addrepo", repoURL) addRepoCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper addrepo %s", repoURL))
addRepoCmd.Stdout = os.Stdout addRepoCmd.Stdout = os.Stdout
addRepoCmd.Stderr = os.Stderr addRepoCmd.Stderr = os.Stderr
addRepoCmd.Run() addRepoCmd.Run()
exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run() privesc.ExecCommand(ctx, sudoPassword, "zypper refresh").Run()
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y dms-greeter")
case distros.FamilyUbuntu: case distros.FamilyUbuntu:
failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter" failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter"
logFunc("Enabling PPA ppa:avengemedia/danklinux...") logFunc("Enabling PPA ppa:avengemedia/danklinux...")
ppacmd := exec.CommandContext(ctx, "sudo", "add-apt-repository", "-y", "ppa:avengemedia/danklinux") ppacmd := privesc.ExecCommand(ctx, sudoPassword, "add-apt-repository -y ppa:avengemedia/danklinux")
ppacmd.Stdout = os.Stdout ppacmd.Stdout = os.Stdout
ppacmd.Stderr = os.Stderr ppacmd.Stderr = os.Stderr
ppacmd.Run() ppacmd.Run()
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
case distros.FamilyFedora: case distros.FamilyFedora:
failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter" failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter"
logFunc("Enabling COPR avengemedia/danklinux...") logFunc("Enabling COPR avengemedia/danklinux...")
coprcmd := exec.CommandContext(ctx, "sudo", "dnf", "copr", "enable", "-y", "avengemedia/danklinux") coprcmd := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y avengemedia/danklinux")
coprcmd.Stdout = os.Stdout coprcmd.Stdout = os.Stdout
coprcmd.Stderr = os.Stderr coprcmd.Stderr = os.Stderr
coprcmd.Run() coprcmd.Run()
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "dms-greeter") installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y dms-greeter")
case distros.FamilyArch: case distros.FamilyArch:
aurHelper := "" aurHelper := ""
for _, helper := range []string{"paru", "yay"} { for _, helper := range []string{"paru", "yay"} {
@@ -557,25 +519,25 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() { if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() {
action = "Updated" action = "Updated"
} }
if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err) return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err)
} }
logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst)) logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst))
if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "+x", wrapperDst); err != nil {
return fmt.Errorf("failed to make wrapper executable: %w", err) return fmt.Errorf("failed to make wrapper executable: %w", err)
} }
osInfo, err := distros.GetOSInfo() osInfo, err := distros.GetOSInfo()
if err == nil { if err == nil {
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) { 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 { if err := privesc.Run(context.Background(), sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err))
} else { } else {
logFunc("✓ Set SELinux fcontext for dms-greeter") logFunc("✓ Set SELinux fcontext for dms-greeter")
} }
if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err))
} else { } else {
logFunc("✓ Restored SELinux context for dms-greeter") logFunc("✓ Restored SELinux context for dms-greeter")
@@ -601,7 +563,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat cache directory: %w", err) return fmt.Errorf("failed to stat cache directory: %w", err)
} }
if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", cacheDir); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
created = true created = true
@@ -613,17 +575,17 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
daemonUser := DetectGreeterUser() daemonUser := DetectGreeterUser()
preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group) preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group)
owner := preferredOwner owner := preferredOwner
if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, cacheDir); err != nil {
// Some setups may not have a matching daemon user at this moment; fall back // Some setups may not have a matching daemon user at this moment; fall back
// to root:<group> while still allowing group-writable greeter runtime access. // to root:<group> while still allowing group-writable greeter runtime access.
fallbackOwner := fmt.Sprintf("root:%s", group) fallbackOwner := fmt.Sprintf("root:%s", group)
if fallbackErr := runSudoCmd(sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil { if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil {
return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr) return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr)
} }
owner = fallbackOwner owner = fallbackOwner
} }
if err := runSudoCmd(sudoPassword, "chmod", "2770", cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory permissions: %w", err) return fmt.Errorf("failed to set cache directory permissions: %w", err)
} }
@@ -635,13 +597,13 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
filepath.Join(cacheDir, ".cache"), filepath.Join(cacheDir, ".cache"),
} }
for _, dir := range runtimeDirs { for _, dir := range runtimeDirs {
if err := runSudoCmd(sudoPassword, "mkdir", "-p", dir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", dir); err != nil {
return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err) return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err)
} }
if err := runSudoCmd(sudoPassword, "chown", owner, dir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, dir); err != nil {
return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err) return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "2770", dir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", dir); err != nil {
return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err) return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err)
} }
} }
@@ -653,7 +615,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
} }
if isSELinuxEnforcing() && utils.CommandExists("restorecon") { if isSELinuxEnforcing() && utils.CommandExists("restorecon") {
if err := runSudoCmd(sudoPassword, "restorecon", "-Rv", cacheDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-Rv", cacheDir); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err))
} }
} }
@@ -678,13 +640,13 @@ func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPat
info, err := os.Lstat(legacyPath) info, err := os.Lstat(legacyPath)
if err == nil && info.Mode().IsRegular() { if err == nil && info.Mode().IsRegular() {
if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) { if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) {
if copyErr := runSudoCmd(sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil { if copyErr := privesc.Run(context.Background(), sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr)) logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr))
} }
} }
} }
if err := runSudoCmd(sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil {
return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err) return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err)
} }
@@ -711,7 +673,7 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
return nil return nil
} }
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil {
return fmt.Errorf("failed to create /etc/apparmor.d: %w", err) return fmt.Errorf("failed to create /etc/apparmor.d: %w", err)
} }
@@ -728,15 +690,15 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
} }
tmp.Close() tmp.Close()
if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil {
return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err) return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "644", appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", appArmorProfileDest); err != nil {
return fmt.Errorf("failed to set AppArmor profile permissions: %w", err) return fmt.Errorf("failed to set AppArmor profile permissions: %w", err)
} }
if utils.CommandExists("apparmor_parser") { if utils.CommandExists("apparmor_parser") {
if err := runSudoCmd(sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil {
logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err)) logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err))
logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest) logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest)
} else { } else {
@@ -783,10 +745,10 @@ func RemoveGreeterPamManagedBlock(logFunc func(string), sudoPassword string) err
} }
tmp.Close() tmp.Close()
if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
return fmt.Errorf("failed to write PAM config: %w", err) return fmt.Errorf("failed to write PAM config: %w", err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", greetdPamPath); err != nil {
return fmt.Errorf("failed to set PAM config permissions: %w", err) return fmt.Errorf("failed to set PAM config permissions: %w", err)
} }
logFunc(" ✓ Removed DMS managed PAM block from " + greetdPamPath) logFunc(" ✓ Removed DMS managed PAM block from " + greetdPamPath)
@@ -807,9 +769,9 @@ func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
} }
if utils.CommandExists("apparmor_parser") { if utils.CommandExists("apparmor_parser") {
_ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest) _ = privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest)
} }
if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", appArmorProfileDest); err != nil {
return fmt.Errorf("failed to remove AppArmor profile: %w", err) return fmt.Errorf("failed to remove AppArmor profile: %w", err)
} }
logFunc(" ✓ Removed DMS AppArmor profile") logFunc(" ✓ Removed DMS AppArmor profile")
@@ -839,50 +801,17 @@ func EnsureACLInstalled(logFunc func(string), sudoPassword string) error {
switch config.Family { switch config.Family {
case distros.FamilyArch: case distros.FamilyArch:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl")
}
case distros.FamilyFedora: case distros.FamilyFedora:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl")
}
case distros.FamilySUSE: case distros.FamilySUSE:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl") case distros.FamilyUbuntu, distros.FamilyDebian:
} else { installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y acl")
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "acl")
}
case distros.FamilyUbuntu:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
}
case distros.FamilyDebian:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
}
case distros.FamilyGentoo: case distros.FamilyGentoo:
if sudoPassword != "" { installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl")
}
case distros.FamilyNix: case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix") return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix")
default: default:
return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family) return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family)
} }
@@ -939,7 +868,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
} }
// Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora). // Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora).
if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err)) 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 g:%s:rX %s", group, dir.path)) logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path))
continue continue
@@ -996,7 +925,7 @@ func RemediateStaleACLs(logFunc func(string), sudoPassword string) {
continue continue
} }
for _, user := range existingUsers { for _, user := range existingUsers {
_ = runSudoCmd(sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir) _ = privesc.Run(context.Background(), sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir)
cleaned = true cleaned = true
} }
} }
@@ -1036,7 +965,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
// Create the group if it doesn't exist yet (e.g. before greetd package is installed). // Create the group if it doesn't exist yet (e.g. before greetd package is installed).
if !utils.HasGroup(group) { if !utils.HasGroup(group) {
if err := runSudoCmd(sudoPassword, "groupadd", "-r", group); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "groupadd", "-r", group); err != nil {
return fmt.Errorf("failed to create %s group: %w", group, err) return fmt.Errorf("failed to create %s group: %w", group, err)
} }
logFunc(fmt.Sprintf("✓ Created system group %s", group)) logFunc(fmt.Sprintf("✓ Created system group %s", group))
@@ -1047,7 +976,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
if err == nil && strings.Contains(string(groupsOutput), group) { if err == nil && strings.Contains(string(groupsOutput), group) {
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group)) logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
} else { } else {
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err) return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err)
} }
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group)) logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
@@ -1062,7 +991,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
if strings.Contains(string(daemonGroupsOutput), group) { if strings.Contains(string(daemonGroupsOutput), group) {
logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group)) logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group))
} else { } else {
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, daemonUser); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, daemonUser); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err)) logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err))
} else { } else {
logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group)) logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group))
@@ -1092,12 +1021,12 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
} }
} }
if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
continue continue
} }
if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err))
continue continue
} }
@@ -1309,8 +1238,8 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
} }
target := filepath.Join(cacheDir, "colors.json") target := filepath.Join(cacheDir, "colors.json")
_ = runSudoCmd(sudoPassword, "rm", "-f", target) _ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", source, target); err != nil {
return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err) return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err)
} }
@@ -1362,9 +1291,9 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
} }
} }
_ = runSudoCmd(sudoPassword, "rm", "-f", link.target) _ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", link.target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err) return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err)
} }
@@ -1406,13 +1335,13 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error { func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error {
destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
if state.ResolvedGreeterWallpaperPath == "" { if state.ResolvedGreeterWallpaperPath == "" {
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
return fmt.Errorf("failed to clear override file %s: %w", destPath, err) return fmt.Errorf("failed to clear override file %s: %w", destPath, err)
} }
logFunc("✓ Cleared greeter wallpaper override") logFunc("✓ Cleared greeter wallpaper override")
return nil return nil
} }
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
return fmt.Errorf("failed to remove old override file %s: %w", destPath, err) return fmt.Errorf("failed to remove old override file %s: %w", destPath, err)
} }
src := state.ResolvedGreeterWallpaperPath src := state.ResolvedGreeterWallpaperPath
@@ -1423,17 +1352,17 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
if st.IsDir() { if st.IsDir() {
return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src) return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src)
} }
if err := runSudoCmd(sudoPassword, "cp", src, destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", src, destPath); err != nil {
return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err) return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err)
} }
greeterGroup := DetectGreeterGroup() greeterGroup := DetectGreeterGroup()
daemonUser := DetectGreeterUser() daemonUser := DetectGreeterUser()
if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil {
if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil { if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil {
return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err) return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err)
} }
} }
if err := runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", destPath); err != nil {
return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err) return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err)
} }
logFunc("✓ Synced greeter wallpaper override") logFunc("✓ Synced greeter wallpaper override")
@@ -1798,10 +1727,10 @@ func syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword str
if err := tmpFile.Close(); err != nil { if err := tmpFile.Close(); err != nil {
return err return err
} }
if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
return fmt.Errorf("failed to install updated PAM config at %s: %w", greetdPamPath, err) return fmt.Errorf("failed to install updated PAM config at %s: %w", greetdPamPath, err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", greetdPamPath); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", greetdPamPath, err) return fmt.Errorf("failed to set permissions on %s: %w", greetdPamPath, err)
} }
if wantFprint || wantU2f { if wantFprint || wantU2f {
@@ -1860,13 +1789,13 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
greeterDir := "/etc/greetd/niri" greeterDir := "/etc/greetd/niri"
greeterGroup := DetectGreeterGroup() greeterGroup := DetectGreeterGroup()
if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", greeterDir); err != nil {
return fmt.Errorf("failed to create greetd niri directory: %w", err) return fmt.Errorf("failed to create greetd niri directory: %w", err)
} }
if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err) return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
} }
if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "chmod", "755", greeterDir); err != nil {
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err) return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
} }
@@ -1888,7 +1817,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil { if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", dmsPath, err) return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
} }
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
return fmt.Errorf("failed to install greetd niri dms config: %w", err) return fmt.Errorf("failed to install greetd niri dms config: %w", err)
} }
@@ -1911,7 +1840,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil { if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup %s: %w", mainPath, err) return fmt.Errorf("failed to backup %s: %w", mainPath, err)
} }
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
return fmt.Errorf("failed to install greetd niri main config: %w", err) return fmt.Errorf("failed to install greetd niri main config: %w", err)
} }
@@ -1987,7 +1916,7 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi
return fmt.Errorf("failed to close temp greetd config: %w", err) return fmt.Errorf("failed to close temp greetd config: %w", err)
} }
if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to update greetd config: %w", err) return fmt.Errorf("failed to update greetd config: %w", err)
} }
@@ -2003,10 +1932,10 @@ func backupFileIfExists(sudoPassword string, path string, suffix string) error {
} }
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405")) backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "cp", path, backupPath); err != nil {
return err return err
} }
return runSudoCmd(sudoPassword, "chmod", "644", backupPath) return privesc.Run(context.Background(), sudoPassword, "chmod", "644", backupPath)
} }
func (s *niriGreeterSync) processFile(filePath string) error { func (s *niriGreeterSync) processFile(filePath string) error {
@@ -2244,11 +2173,11 @@ vt = 1
return fmt.Errorf("failed to close temp greetd config: %w", err) return fmt.Errorf("failed to close temp greetd config: %w", err)
} }
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err) return fmt.Errorf("failed to create /etc/greetd: %w", err)
} }
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err) return fmt.Errorf("failed to install greetd config: %w", err)
} }
@@ -2352,27 +2281,6 @@ func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string {
return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug) return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug)
} }
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 checkSystemdEnabled(service string) (string, error) { func checkSystemdEnabled(service string) (string, error) {
cmd := exec.Command("systemctl", "is-enabled", service) cmd := exec.Command("systemctl", "is-enabled", service)
output, _ := cmd.Output() output, _ := cmd.Output()
@@ -2389,7 +2297,7 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
switch state { switch state {
case "enabled", "enabled-runtime", "static", "indirect", "alias": case "enabled", "enabled-runtime", "static", "indirect", "alias":
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm)) logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
if err := runSudoCmd(sudoPassword, "systemctl", "disable", dm); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "disable", dm); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err)) logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
} else { } else {
logFunc(fmt.Sprintf("✓ Disabled %s", dm)) logFunc(fmt.Sprintf("✓ Disabled %s", dm))
@@ -2410,13 +2318,13 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
} }
if state == "masked" || state == "masked-runtime" { if state == "masked" || state == "masked-runtime" {
logFunc(" Unmasking greetd...") logFunc(" Unmasking greetd...")
if err := runSudoCmd(sudoPassword, "systemctl", "unmask", "greetd"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "unmask", "greetd"); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err) return fmt.Errorf("failed to unmask greetd: %w", err)
} }
logFunc(" ✓ Unmasked greetd") logFunc(" ✓ Unmasked greetd")
} }
logFunc(" Enabling greetd service (--force)...") logFunc(" Enabling greetd service (--force)...")
if err := runSudoCmd(sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err) return fmt.Errorf("failed to enable greetd: %w", err)
} }
logFunc("✓ greetd enabled") logFunc("✓ greetd enabled")
@@ -2436,7 +2344,7 @@ func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
return nil return nil
} }
logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current)) logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current))
if err := runSudoCmd(sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil { if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil {
return fmt.Errorf("failed to set graphical target: %w", err) return fmt.Errorf("failed to set graphical target: %w", err)
} }
logFunc("✓ Default target set to graphical.target") logFunc("✓ Default target set to graphical.target")

View File

@@ -0,0 +1,385 @@
package privesc
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
)
// Tool identifies a privilege-escalation binary.
type Tool string
const (
ToolSudo Tool = "sudo"
ToolDoas Tool = "doas"
ToolRun0 Tool = "run0"
)
// EnvVar selects a specific tool when set to one of: sudo, doas, run0.
const EnvVar = "DMS_PRIVESC"
var detectionOrder = []Tool{ToolSudo, ToolDoas, ToolRun0}
var (
detectOnce sync.Once
detected Tool
detectErr error
userSelected bool
)
// Detect returns the tool that should be used for privilege escalation.
// The result is cached after the first call.
func Detect() (Tool, error) {
detectOnce.Do(func() {
detected, detectErr = detectTool()
})
return detected, detectErr
}
// ResetForTesting clears cached detection state.
func ResetForTesting() {
detectOnce = sync.Once{}
detected = ""
detectErr = nil
userSelected = false
}
// AvailableTools returns the set of supported tools that are installed on
// PATH, in detection-precedence order.
func AvailableTools() []Tool {
var out []Tool
for _, t := range detectionOrder {
if t.Available() {
out = append(out, t)
}
}
return out
}
// EnvOverride returns the tool selected by the $DMS_PRIVESC env var (if any)
// along with ok=true when the variable is set. An empty or unset variable
// returns ok=false.
func EnvOverride() (Tool, bool) {
v := strings.ToLower(strings.TrimSpace(os.Getenv(EnvVar)))
if v == "" {
return "", false
}
return Tool(v), true
}
// SetTool forces the detected tool to t, bypassing autodetection. Intended
// for use after the caller has prompted the user for a selection.
func SetTool(t Tool) error {
if !t.Available() {
return fmt.Errorf("%q is not installed", t.Name())
}
detectOnce = sync.Once{}
detectOnce.Do(func() {
detected = t
detectErr = nil
})
userSelected = true
return nil
}
func detectTool() (Tool, error) {
switch override := strings.ToLower(strings.TrimSpace(os.Getenv(EnvVar))); override {
case "":
// fall through to autodetect
case string(ToolSudo), string(ToolDoas), string(ToolRun0):
t := Tool(override)
if !t.Available() {
return "", fmt.Errorf("%s=%s but %q is not installed", EnvVar, override, t.Name())
}
return t, nil
default:
return "", fmt.Errorf("invalid %s=%q: must be one of sudo, doas, run0", EnvVar, override)
}
for _, t := range detectionOrder {
if t.Available() {
return t, nil
}
}
return "", fmt.Errorf("no supported privilege escalation tool found (tried: sudo, doas, run0)")
}
// Name returns the binary name.
func (t Tool) Name() string { return string(t) }
// Available reports whether this tool's binary is on PATH.
func (t Tool) Available() bool {
if t == "" {
return false
}
_, err := exec.LookPath(string(t))
return err == nil
}
// SupportsStdinPassword reports whether the tool can accept a password via
// stdin. Only sudo (-S) supports this.
func (t Tool) SupportsStdinPassword() bool {
return t == ToolSudo
}
// EscapeSingleQuotes escapes single quotes for safe inclusion inside a
// bash single-quoted string.
func EscapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
// MakeCommand returns a bash command string that runs `command` with the
// detected tool. When the tool supports stdin passwords and password is
// non-empty, the password is piped in. Otherwise the tool is invoked with
// no non-interactive flag so that an interactive TTY prompt is still
// possible for CLI callers.
//
// If detection fails, the returned shell string exits 1 with an error
// message so callers that treat the *exec.Cmd as infallible still fail
// deterministically.
func MakeCommand(password, command string) string {
t, err := Detect()
if err != nil {
return failingShell(err)
}
switch t {
case ToolSudo:
if password != "" {
return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command)
}
return fmt.Sprintf("sudo %s", command)
case ToolDoas:
return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command))
case ToolRun0:
return fmt.Sprintf("run0 sh -c '%s'", EscapeSingleQuotes(command))
default:
return failingShell(fmt.Errorf("unsupported privilege tool: %q", t))
}
}
// ExecCommand builds an exec.Cmd that runs `command` as root via the
// detected tool. Detection errors surface at Run() time as a failing
// command writing a clear error to stderr.
func ExecCommand(ctx context.Context, password, command string) *exec.Cmd {
return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command))
}
// ExecArgv builds an exec.Cmd that runs argv as root via the detected tool.
// No stdin password is supplied; callers relying on non-interactive success
// should ensure cached credentials are present (see CheckCached).
func ExecArgv(ctx context.Context, argv ...string) *exec.Cmd {
if len(argv) == 0 {
return exec.CommandContext(ctx, "bash", "-c", failingShell(fmt.Errorf("privesc.ExecArgv: argv must not be empty")))
}
t, err := Detect()
if err != nil {
return exec.CommandContext(ctx, "bash", "-c", failingShell(err))
}
switch t {
case ToolSudo, ToolDoas:
return exec.CommandContext(ctx, string(t), argv...)
case ToolRun0:
return exec.CommandContext(ctx, "run0", argv...)
default:
return exec.CommandContext(ctx, "bash", "-c", failingShell(fmt.Errorf("unsupported privilege tool: %q", t)))
}
}
func failingShell(err error) string {
return fmt.Sprintf("printf 'privesc: %%s\\n' '%s' >&2; exit 1", EscapeSingleQuotes(err.Error()))
}
// CheckCached runs a non-interactive credential probe. Returns nil if the
// tool will run commands without prompting (cached credentials, nopass, or
// polkit rule).
func CheckCached(ctx context.Context) error {
t, err := Detect()
if err != nil {
return err
}
var cmd *exec.Cmd
switch t {
case ToolSudo:
cmd = exec.CommandContext(ctx, "sudo", "-n", "true")
case ToolDoas:
cmd = exec.CommandContext(ctx, "doas", "-n", "true")
case ToolRun0:
cmd = exec.CommandContext(ctx, "run0", "--no-ask-password", "true")
default:
return fmt.Errorf("unsupported privilege tool: %q", t)
}
return cmd.Run()
}
// ClearCache invalidates any cached credentials. No-op for tools that do
// not expose a cache-clear operation.
func ClearCache(ctx context.Context) error {
t, err := Detect()
if err != nil {
return err
}
switch t {
case ToolSudo:
return exec.CommandContext(ctx, "sudo", "-k").Run()
default:
return nil
}
}
// ValidateWithAskpass validates cached credentials using an askpass helper
// script. Only sudo supports this mechanism; the TUI uses it to trigger
// fingerprint authentication via PAM.
func ValidateWithAskpass(ctx context.Context, askpassScript string) error {
t, err := Detect()
if err != nil {
return err
}
if t != ToolSudo {
return fmt.Errorf("askpass validation requires sudo (detected: %s)", t)
}
cmd := exec.CommandContext(ctx, "sudo", "-A", "-v")
cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript))
return cmd.Run()
}
// ValidatePassword validates the given password. Only sudo supports this
// (via `sudo -S -v`); for other tools the caller should fall back to
// CheckCached.
func ValidatePassword(ctx context.Context, password string) error {
t, err := Detect()
if err != nil {
return err
}
if t != ToolSudo {
return fmt.Errorf("password validation requires sudo (detected: %s)", t)
}
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
if _, err := fmt.Fprintf(stdin, "%s\n", password); err != nil {
stdin.Close()
_ = cmd.Wait()
return err
}
stdin.Close()
return cmd.Wait()
}
// QuoteArgsForShell wraps each argv element in single quotes so the result
// can be safely passed to bash -c.
func QuoteArgsForShell(argv []string) string {
parts := make([]string, len(argv))
for i, a := range argv {
parts[i] = "'" + EscapeSingleQuotes(a) + "'"
}
return strings.Join(parts, " ")
}
// Run invokes argv with privilege escalation. When the tool supports stdin
// passwords and password is non-empty, the password is piped in. Otherwise
// argv is invoked directly, which may prompt on a TTY.
// Stdout and Stderr are inherited from the current process.
func Run(ctx context.Context, password string, argv ...string) error {
if len(argv) == 0 {
return fmt.Errorf("privesc.Run: argv must not be empty")
}
t, err := Detect()
if err != nil {
return err
}
var cmd *exec.Cmd
switch {
case t == ToolSudo && password != "":
cmd = ExecCommand(ctx, password, QuoteArgsForShell(argv))
default:
cmd = ExecArgv(ctx, argv...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// stdinIsTTY reports whether stdin is a character device (interactive
// terminal) rather than a pipe or file.
func stdinIsTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
// PromptCLI interactively prompts the user to pick a privilege tool when more
// than one is installed and $DMS_PRIVESC is not set. If stdin is not a TTY,
// or only one tool is available, or the env var is set, the detected tool is
// returned without any prompt.
//
// The prompt is written to out (typically os.Stdout/os.Stderr) and input is
// read from in. EOF or empty input selects the first option.
func PromptCLI(out io.Writer, in io.Reader) (Tool, error) {
if userSelected {
return Detect()
}
if _, envSet := EnvOverride(); envSet {
return Detect()
}
tools := AvailableTools()
switch len(tools) {
case 0:
return "", fmt.Errorf("no supported privilege tool (sudo/doas/run0) found on PATH")
case 1:
if err := SetTool(tools[0]); err != nil {
return "", err
}
return tools[0], nil
}
if !stdinIsTTY() {
return Detect()
}
fmt.Fprintln(out, "Multiple privilege escalation tools detected:")
for i, t := range tools {
fmt.Fprintf(out, " [%d] %s\n", i+1, t.Name())
}
fmt.Fprintf(out, "Choose one [1-%d] (default 1, or set %s=<tool> to skip): ", len(tools), EnvVar)
reader := bufio.NewReader(in)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return "", fmt.Errorf("failed to read selection: %w", err)
}
line = strings.TrimSpace(line)
idx := 1
if line != "" {
n, convErr := strconv.Atoi(line)
if convErr != nil || n < 1 || n > len(tools) {
return "", fmt.Errorf("invalid selection %q", line)
}
idx = n
}
chosen := tools[idx-1]
if err := SetTool(chosen); err != nil {
return "", err
}
return chosen, nil
}

View File

@@ -391,7 +391,7 @@ func (m *Manager) Close() {
func InitializeManager() (*Manager, error) { func InitializeManager() (*Manager, error) {
if os.Getuid() != 0 && !hasInputGroupAccess() { if os.Getuid() != 0 && !hasInputGroupAccess() {
return nil, fmt.Errorf("insufficient permissions to access input devices") return nil, fmt.Errorf("insufficient permissions to access input devices. Add your user to the 'input' group: `sudo usermod -a -G input $USER` or run `dms setup`")
} }
return NewManager() return NewManager()

View File

@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
return false return false
} }
if reply != dbus.RequestNameReplyPrimaryOwner { if reply != dbus.RequestNameReplyPrimaryOwner {
log.Warnf("Screensaver name %s already owned by another process", name) log.Infof("Screensaver name %s already owned by another process (e.g. hypridle/swayidle)", name)
return false return false
} }
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil { if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {

View File

@@ -3,6 +3,7 @@ package tui
import ( import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -42,6 +43,9 @@ type Model struct {
sudoPassword string sudoPassword string
existingConfigs []ExistingConfigInfo existingConfigs []ExistingConfigInfo
fingerprintFailed bool fingerprintFailed bool
availablePrivesc []privesc.Tool
selectedPrivesc int
} }
func NewModel(version string, logFilePath string) Model { func NewModel(version string, logFilePath string) Model {
@@ -147,6 +151,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateGentooUseFlagsState(msg) return m.updateGentooUseFlagsState(msg)
case StateGentooGCCCheck: case StateGentooGCCCheck:
return m.updateGentooGCCCheckState(msg) return m.updateGentooGCCCheckState(msg)
case StateSelectPrivesc:
return m.updateSelectPrivescState(msg)
case StateAuthMethodChoice: case StateAuthMethodChoice:
return m.updateAuthMethodChoiceState(msg) return m.updateAuthMethodChoiceState(msg)
case StateFingerprintAuth: case StateFingerprintAuth:
@@ -189,6 +195,8 @@ func (m Model) View() string {
return m.viewGentooUseFlags() return m.viewGentooUseFlags()
case StateGentooGCCCheck: case StateGentooGCCCheck:
return m.viewGentooGCCCheck() return m.viewGentooGCCCheck()
case StateSelectPrivesc:
return m.viewSelectPrivesc()
case StateAuthMethodChoice: case StateAuthMethodChoice:
return m.viewAuthMethodChoice() return m.viewAuthMethodChoice()
case StateFingerprintAuth: case StateFingerprintAuth:

View File

@@ -10,6 +10,7 @@ const (
StateDependencyReview StateDependencyReview
StateGentooUseFlags StateGentooUseFlags
StateGentooGCCCheck StateGentooGCCCheck
StateSelectPrivesc
StateAuthMethodChoice StateAuthMethodChoice
StateFingerprintAuth StateFingerprintAuth
StatePasswordPrompt StatePasswordPrompt

View File

@@ -180,16 +180,7 @@ func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
} }
// Check if fingerprint is enabled return m.enterAuthPhase()
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0 // Default to fingerprint
return m, nil
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
return m, nil
}
case "esc": case "esc":
m.state = StateSelectWindowManager m.state = StateSelectWindowManager
return m, nil return m, nil

View File

@@ -56,14 +56,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = StateGentooGCCCheck m.state = StateGentooGCCCheck
return m, nil return m, nil
} }
if checkFingerprintEnabled() { return m.enterAuthPhase()
m.state = StateAuthMethodChoice
m.selectedConfig = 0
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
}
return m, nil
} }
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
@@ -75,14 +68,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.selectedWM == 1 { if m.selectedWM == 1 {
return m, m.checkGCCVersion() return m, m.checkGCCVersion()
} }
if checkFingerprintEnabled() { return m.enterAuthPhase()
m.state = StateAuthMethodChoice
m.selectedConfig = 0
} else {
m.state = StatePasswordPrompt
m.passwordInput.Focus()
}
return m, nil
case "esc": case "esc":
m.state = StateDependencyReview m.state = StateDependencyReview
return m, nil return m, nil

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -274,8 +275,7 @@ func (m Model) delayThenReturn() tea.Cmd {
func (m Model) tryFingerprint() tea.Cmd { func (m Model) tryFingerprint() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
clearCmd := exec.Command("sudo", "-k") _ = privesc.ClearCache(context.Background())
clearCmd.Run()
tmpDir := os.TempDir() tmpDir := os.TempDir()
askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano())) askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano()))
@@ -289,15 +289,9 @@ func (m Model) tryFingerprint() tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-A", "-v") if err := privesc.ValidateWithAskpass(ctx, askpassScript); err != nil {
cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript))
err := cmd.Run()
if err != nil {
return passwordValidMsg{password: "", valid: false} return passwordValidMsg{password: "", valid: false}
} }
return passwordValidMsg{password: "", valid: true} return passwordValidMsg{password: "", valid: true}
} }
} }
@@ -307,32 +301,9 @@ func (m Model) validatePassword(password string) tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v") if err := privesc.ValidatePassword(ctx, password); err != nil {
stdin, err := cmd.StdinPipe()
if err != nil {
return passwordValidMsg{password: "", valid: false} return passwordValidMsg{password: "", valid: false}
} }
if err := cmd.Start(); err != nil {
return passwordValidMsg{password: "", valid: false}
}
_, err = fmt.Fprintf(stdin, "%s\n", password)
stdin.Close()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
err = cmd.Wait()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: password, valid: true} return passwordValidMsg{password: password, valid: true}
} }
} }

View File

@@ -0,0 +1,133 @@
package tui
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) viewSelectPrivesc() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
b.WriteString(m.styles.Title.Render("Privilege Escalation Tool"))
b.WriteString("\n\n")
b.WriteString(m.styles.Normal.Render("Multiple privilege tools are available. Choose one for installation:"))
b.WriteString("\n\n")
for i, t := range m.availablePrivesc {
label := fmt.Sprintf("%s — %s", t.Name(), privescToolDescription(t))
switch i {
case m.selectedPrivesc:
b.WriteString(m.styles.SelectedOption.Render("▶ " + label))
default:
b.WriteString(m.styles.Normal.Render(" " + label))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.styles.Subtle.Render(fmt.Sprintf("Set %s=<tool> to skip this prompt in future runs.", privesc.EnvVar)))
b.WriteString("\n\n")
b.WriteString(m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back"))
return b.String()
}
func (m Model) updateSelectPrivescState(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, m.listenForLogs()
}
switch keyMsg.String() {
case "up":
if m.selectedPrivesc > 0 {
m.selectedPrivesc--
}
case "down":
if m.selectedPrivesc < len(m.availablePrivesc)-1 {
m.selectedPrivesc++
}
case "enter":
chosen := m.availablePrivesc[m.selectedPrivesc]
if err := privesc.SetTool(chosen); err != nil {
m.err = fmt.Errorf("failed to select %s: %w", chosen.Name(), err)
m.state = StateError
return m, nil
}
return m.routeToAuthAfterPrivesc()
case "esc":
m.state = StateDependencyReview
return m, nil
}
return m, nil
}
func privescToolDescription(t privesc.Tool) string {
switch t {
case privesc.ToolSudo:
return "classic sudo (supports password prompt in this installer)"
case privesc.ToolDoas:
return "OpenBSD-style doas (requires persist or nopass in /etc/doas.conf)"
case privesc.ToolRun0:
return "systemd run0 (authenticated via polkit)"
default:
return string(t)
}
}
// routeToAuthAfterPrivesc advances from the privesc-selection screen to the
// right auth flow. Sudo goes through the fingerprint/password path; doas and
// run0 skip password entry and proceed to install.
func (m Model) routeToAuthAfterPrivesc() (tea.Model, tea.Cmd) {
tool, err := privesc.Detect()
if err != nil {
m.err = err
m.state = StateError
return m, nil
}
if tool == privesc.ToolSudo {
if checkFingerprintEnabled() {
m.state = StateAuthMethodChoice
m.selectedConfig = 0
return m, nil
}
m.state = StatePasswordPrompt
m.passwordInput.Focus()
return m, nil
}
m.sudoPassword = ""
m.packageProgress = packageInstallProgressMsg{}
m.state = StateInstallingPackages
m.isLoading = true
return m, tea.Batch(m.spinner.Tick, m.installPackages())
}
// enterAuthPhase is called when dependency review (or the Gentoo screens)
// finish. It either routes directly to the sudo/fingerprint flow or shows
// the privesc-tool selection screen when multiple tools are available and
// no $DMS_PRIVESC override is set.
func (m Model) enterAuthPhase() (tea.Model, tea.Cmd) {
tools := privesc.AvailableTools()
_, envSet := privesc.EnvOverride()
if len(tools) == 0 {
m.err = fmt.Errorf("no supported privilege tool (sudo/doas/run0) found on PATH")
m.state = StateError
return m, nil
}
if envSet || len(tools) == 1 {
return m.routeToAuthAfterPrivesc()
}
m.availablePrivesc = tools
m.selectedPrivesc = 0
m.state = StateSelectPrivesc
return m, nil
}

157
flake.nix
View File

@@ -41,10 +41,11 @@
nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] ( nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] (
system: fn system nixpkgs.legacyPackages.${system} system: fn system nixpkgs.legacyPackages.${system}
); );
buildDmsPkgs = pkgs: { forEachLinuxSystem =
dms-shell = self.packages.${pkgs.stdenv.hostPlatform.system}.default; fn:
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default; nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (
}; system: fn system nixpkgs.legacyPackages.${system}
);
mkModuleWithDmsPkgs = mkModuleWithDmsPkgs =
modulePath: modulePath:
args@{ pkgs, ... }: args@{ pkgs, ... }:
@@ -53,6 +54,7 @@
(import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; })) (import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; }))
]; ];
}; };
mkQmlImportPath = mkQmlImportPath =
pkgs: qmlPkgs: pkgs: qmlPkgs:
pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs); pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs);
@@ -69,10 +71,11 @@
qtimageformats qtimageformats
kimageformats kimageformats
]; ];
in
{ # Allows downstream modules to provide their own 'pkgs' (with overlays)
packages = forEachSystem ( # instead of being forced to use the flake's locked nixpkgs.
system: pkgs: mkDmsShell =
pkgs:
let let
mkDate = mkDate =
longDate: longDate:
@@ -90,86 +93,96 @@
in in
"${cleanVersion}${dateSuffix}${revSuffix}"; "${cleanVersion}${dateSuffix}${revSuffix}";
in in
{ pkgs.lib.makeOverridable (
dms-shell = pkgs.lib.makeOverridable ( {
extraQtPackages ? [ ],
}:
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
let
rootSrc = ./.;
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
in
{ {
extraQtPackages ? [ ], inherit version;
}: pname = "dms-shell";
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) ( src = ./core;
let vendorHash = "sha256-cVUJXgzYMRSM0od1xzDVkMTdxHu3OIQX2bQ8AJbGQ1Q=";
rootSrc = ./.;
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
in
{
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-cVUJXgzYMRSM0od1xzDVkMTdxHu3OIQX2bQ8AJbGQ1Q=";
subPackages = [ "cmd/dms" ]; subPackages = [ "cmd/dms" ];
ldflags = [ ldflags = [
"-s" "-s"
"-w" "-w"
"-X 'main.Version=${version}'" "-X 'main.Version=${version}'"
]; ];
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
installShellFiles installShellFiles
makeWrapper makeWrapper
]; ];
postInstall = '' postInstall = ''
mkdir -p $out/share/quickshell/dms mkdir -p $out/share/quickshell/dms
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/ cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
chmod u+w $out/share/quickshell/dms/VERSION chmod u+w $out/share/quickshell/dms/VERSION
echo "${version}" > $out/share/quickshell/dms/VERSION echo "${version}" > $out/share/quickshell/dms/VERSION
# Install desktop file and icon # Install desktop file and icon
install -D ${rootSrc}/assets/dms-open.desktop \ install -D ${rootSrc}/assets/dms-open.desktop \
$out/share/applications/dms-open.desktop $out/share/applications/dms-open.desktop
install -D ${rootSrc}/core/assets/danklogo.svg \ install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg $out/share/hicolor/scalable/apps/danklogo.svg
wrapProgram $out/bin/dms \ wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \ --add-flags "-c $out/share/quickshell/dms" \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \ --prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}" --prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
install -Dm644 ${rootSrc}/assets/systemd/dms.service \ install -Dm644 ${rootSrc}/assets/systemd/dms.service \
$out/lib/systemd/user/dms.service $out/lib/systemd/user/dms.service
substituteInPlace $out/lib/systemd/user/dms.service \ substituteInPlace $out/lib/systemd/user/dms.service \
--replace-fail /usr/bin/dms $out/bin/dms \ --replace-fail /usr/bin/dms $out/bin/dms \
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill --replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \ substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash --replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \ substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so --replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
installShellCompletion --cmd dms \ substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
--bash <($out/bin/dms completion bash) \ --replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
--fish <($out/bin/dms completion fish) \
--zsh <($out/bin/dms completion zsh)
'';
meta = { installShellCompletion --cmd dms \
description = "Desktop shell for wayland compositors built with Quickshell & GO"; --bash <($out/bin/dms completion bash) \
homepage = "https://danklinux.com"; --fish <($out/bin/dms completion fish) \
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}"; --zsh <($out/bin/dms completion zsh)
license = pkgs.lib.licenses.mit; '';
mainProgram = "dms";
platforms = pkgs.lib.platforms.linux;
};
}
)
) { };
meta = {
description = "Desktop shell for wayland compositors built with Quickshell & GO";
homepage = "https://danklinux.com";
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
license = pkgs.lib.licenses.mit;
mainProgram = "dms";
platforms = pkgs.lib.platforms.linux;
};
}
)
) { };
buildDmsPkgs = pkgs: {
dms-shell = mkDmsShell pkgs;
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
in
{
packages = forEachSystem (
system: pkgs: {
dms-shell = mkDmsShell pkgs;
quickshell = quickshell.packages.${system}.default; quickshell = quickshell.packages.${system}.default;
default = self.packages.${system}.dms-shell; default = self.packages.${system}.dms-shell;
} }
); );

View File

@@ -115,4 +115,8 @@ Singleton {
return translations[context][term]; return translations[context][term];
return term; return term;
} }
function locale() {
return Qt.locale();
}
} }

View File

@@ -314,12 +314,6 @@ Singleton {
property bool greeterEnableFprint: false property bool greeterEnableFprint: false
property bool greeterEnableU2f: false property bool greeterEnableU2f: false
property string greeterWallpaperPath: "" property string greeterWallpaperPath: ""
property bool greeterUse24HourClock: true
property bool greeterShowSeconds: false
property bool greeterPadHours12Hour: false
property string greeterLockDateFormat: ""
property string greeterFontFamily: ""
property string greeterWallpaperFillMode: ""
property int mediaSize: 1 property int mediaSize: 1
property string appLauncherViewMode: "list" property string appLauncherViewMode: "list"
@@ -448,6 +442,11 @@ Singleton {
property bool syncModeWithPortal: true property bool syncModeWithPortal: true
property bool terminalsAlwaysDark: false property bool terminalsAlwaysDark: false
property string muxType: "tmux"
property bool muxUseCustomCommand: false
property string muxCustomCommand: ""
property string muxSessionFilter: ""
property bool runDmsMatugenTemplates: true property bool runDmsMatugenTemplates: true
property bool matugenTemplateGtk: true property bool matugenTemplateGtk: true
property bool matugenTemplateNiri: true property bool matugenTemplateNiri: true

View File

@@ -168,12 +168,6 @@ var SPEC = {
greeterEnableFprint: { def: false }, greeterEnableFprint: { def: false },
greeterEnableU2f: { def: false }, greeterEnableU2f: { def: false },
greeterWallpaperPath: { def: "" }, greeterWallpaperPath: { def: "" },
greeterUse24HourClock: { def: true },
greeterShowSeconds: { def: false },
greeterPadHours12Hour: { def: false },
greeterLockDateFormat: { def: "" },
greeterFontFamily: { def: "" },
greeterWallpaperFillMode: { def: "" },
mediaSize: { def: 1 }, mediaSize: { def: 1 },
appLauncherViewMode: { def: "list" }, appLauncherViewMode: { def: "list" },
@@ -266,6 +260,11 @@ var SPEC = {
syncModeWithPortal: { def: true }, syncModeWithPortal: { def: true },
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" }, terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
muxType: { def: "tmux" },
muxUseCustomCommand: { def: false },
muxCustomCommand: { def: "" },
muxSessionFilter: { def: "" },
runDmsMatugenTemplates: { def: true }, runDmsMatugenTemplates: { def: true },
matugenTemplateGtk: { def: true }, matugenTemplateGtk: { def: true },
matugenTemplateNiri: { def: true }, matugenTemplateNiri: { def: true },

View File

@@ -7,6 +7,7 @@ import qs.Modals.Clipboard
import qs.Modals.Greeter import qs.Modals.Greeter
import qs.Modals.Settings import qs.Modals.Settings
import qs.Modals.DankLauncherV2 import qs.Modals.DankLauncherV2
import qs.Modals
import qs.Modules import qs.Modules
import qs.Modules.AppDrawer import qs.Modules.AppDrawer
import qs.Modules.DankDash import qs.Modules.DankDash
@@ -638,6 +639,10 @@ Item {
} }
} }
MuxModal {
id: muxModal
}
ClipboardHistoryModal { ClipboardHistoryModal {
id: clipboardHistoryModalPopup id: clipboardHistoryModalPopup
@@ -1018,12 +1023,6 @@ Item {
} }
} }
Loader {
id: powerProfileWatcherLoader
active: SettingsData.osdPowerProfileEnabled
source: "Services/PowerProfileWatcher.qml"
}
LazyLoader { LazyLoader {
id: hyprlandOverviewLoader id: hyprlandOverviewLoader
active: CompositorService.isHyprland active: CompositorService.isHyprland

View File

@@ -0,0 +1,312 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Widgets
DankModal {
id: root
layerNamespace: "dms:input-modal"
keepPopoutsOpen: true
property string inputTitle: ""
property string inputMessage: ""
property string inputPlaceholder: ""
property string inputText: ""
property string confirmButtonText: "Confirm"
property string cancelButtonText: "Cancel"
property color confirmButtonColor: Theme.primary
property var onConfirm: function (text) {}
property var onCancel: function () {}
property int selectedButton: -1
property bool keyboardNavigation: false
function show(title, message, onConfirmCallback, onCancelCallback) {
inputTitle = title || "";
inputMessage = message || "";
inputPlaceholder = "";
inputText = "";
confirmButtonText = "Confirm";
cancelButtonText = "Cancel";
confirmButtonColor = Theme.primary;
onConfirm = onConfirmCallback || ((text) => {});
onCancel = onCancelCallback || (() => {});
selectedButton = -1;
keyboardNavigation = false;
open();
}
function showWithOptions(options) {
inputTitle = options.title || "";
inputMessage = options.message || "";
inputPlaceholder = options.placeholder || "";
inputText = options.initialText || "";
confirmButtonText = options.confirmText || "Confirm";
cancelButtonText = options.cancelText || "Cancel";
confirmButtonColor = options.confirmColor || Theme.primary;
onConfirm = options.onConfirm || ((text) => {});
onCancel = options.onCancel || (() => {});
selectedButton = -1;
keyboardNavigation = false;
open();
}
function confirmAndClose() {
const text = inputText;
close();
if (onConfirm) {
onConfirm(text);
}
}
function cancelAndClose() {
close();
if (onCancel) {
onCancel();
}
}
function selectButton() {
if (selectedButton === 0) {
cancelAndClose();
} else {
confirmAndClose();
}
}
shouldBeVisible: false
allowStacking: true
modalWidth: 350
modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 200
enableShadow: true
shouldHaveFocus: true
onBackgroundClicked: cancelAndClose()
onOpened: {
Qt.callLater(function () {
if (contentLoader.item && contentLoader.item.textInputRef) {
contentLoader.item.textInputRef.forceActiveFocus();
}
});
}
content: Component {
FocusScope {
anchors.fill: parent
implicitHeight: mainColumn.implicitHeight
focus: true
property alias textInputRef: textInput
Keys.onPressed: function (event) {
const textFieldFocused = textInput.activeFocus;
switch (event.key) {
case Qt.Key_Escape:
root.cancelAndClose();
event.accepted = true;
break;
case Qt.Key_Tab:
if (textFieldFocused) {
root.keyboardNavigation = true;
root.selectedButton = 0;
textInput.focus = false;
} else {
root.keyboardNavigation = true;
if (root.selectedButton === -1) {
root.selectedButton = 0;
} else if (root.selectedButton === 0) {
root.selectedButton = 1;
} else {
root.selectedButton = -1;
textInput.forceActiveFocus();
}
}
event.accepted = true;
break;
case Qt.Key_Left:
if (!textFieldFocused) {
root.keyboardNavigation = true;
root.selectedButton = 0;
event.accepted = true;
}
break;
case Qt.Key_Right:
if (!textFieldFocused) {
root.keyboardNavigation = true;
root.selectedButton = 1;
event.accepted = true;
}
break;
case Qt.Key_Return:
case Qt.Key_Enter:
if (root.selectedButton !== -1) {
root.selectButton();
} else {
root.confirmAndClose();
}
event.accepted = true;
break;
}
}
Column {
id: mainColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
anchors.topMargin: Theme.spacingL
spacing: 0
StyledText {
text: root.inputTitle
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
horizontalAlignment: Text.AlignHCenter
}
Item {
width: 1
height: Theme.spacingL
}
StyledText {
text: root.inputMessage
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
visible: root.inputMessage !== ""
}
Item {
width: 1
height: root.inputMessage !== "" ? Theme.spacingL : 0
visible: root.inputMessage !== ""
}
Rectangle {
width: parent.width
height: 40
radius: Theme.cornerRadius
color: Theme.surfaceVariantAlpha
border.color: textInput.activeFocus ? Theme.primary : "transparent"
border.width: textInput.activeFocus ? 1 : 0
TextInput {
id: textInput
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
verticalAlignment: TextInput.AlignVCenter
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
selectionColor: Theme.primary
selectedTextColor: Theme.primaryText
clip: true
text: root.inputText
onTextChanged: root.inputText = text
StyledText {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
text: root.inputPlaceholder
visible: textInput.text === "" && !textInput.activeFocus
}
}
}
Item {
width: 1
height: Theme.spacingL * 1.5
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
Rectangle {
width: 120
height: 40
radius: Theme.cornerRadius
color: {
if (root.keyboardNavigation && root.selectedButton === 0) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12);
} else if (cancelButton.containsMouse) {
return Theme.surfacePressed;
} else {
return Theme.surfaceVariantAlpha;
}
}
border.color: (root.keyboardNavigation && root.selectedButton === 0) ? Theme.primary : "transparent"
border.width: (root.keyboardNavigation && root.selectedButton === 0) ? 1 : 0
StyledText {
text: root.cancelButtonText
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
anchors.centerIn: parent
}
MouseArea {
id: cancelButton
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.cancelAndClose()
}
}
Rectangle {
width: 120
height: 40
radius: Theme.cornerRadius
color: {
const baseColor = root.confirmButtonColor;
if (root.keyboardNavigation && root.selectedButton === 1) {
return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, 1);
} else if (confirmButton.containsMouse) {
return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, 0.9);
} else {
return baseColor;
}
}
border.color: (root.keyboardNavigation && root.selectedButton === 1) ? "white" : "transparent"
border.width: (root.keyboardNavigation && root.selectedButton === 1) ? 1 : 0
StyledText {
text: root.confirmButtonText
font.pixelSize: Theme.fontSizeMedium
color: Theme.primaryText
font.weight: Font.Medium
anchors.centerIn: parent
}
MouseArea {
id: confirmButton
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.confirmAndClose()
}
}
}
Item {
width: 1
height: Theme.spacingL
}
}
}
}
}

View File

@@ -81,7 +81,7 @@ DankModal {
StyledText { StyledText {
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
text: KeybindsService.cheatsheet.title || "Keybinds" text: KeybindsService.cheatsheet.title || i18n("Keybinds")
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold font.weight: Font.Bold
color: Theme.primary color: Theme.primary
@@ -309,10 +309,12 @@ DankModal {
id: keyText id: keyText
anchors.centerIn: parent anchors.centerIn: parent
color: Theme.secondary color: Theme.secondary
text: modelData.key || "" text: (modelData.key || "").replace(/\+/g, " + ")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium font.weight: Font.Medium
isMonospace: true isMonospace: true
elide: Text.ElideRight
width: Math.min(implicitWidth, 148)
} }
} }
@@ -325,6 +327,7 @@ DankModal {
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
opacity: 0.9 opacity: 0.9
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.NoWrap
} }
} }
} }

View File

@@ -0,0 +1,621 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: muxModal
layerNamespace: "dms:mux"
property int selectedIndex: -1
property string searchText: ""
property var filteredSessions: []
function updateFilteredSessions() {
var filtered = []
var lowerSearch = searchText.trim().toLowerCase()
for (var i = 0; i < MuxService.sessions.length; i++) {
var session = MuxService.sessions[i]
if (lowerSearch.length > 0 && !session.name.toLowerCase().includes(lowerSearch))
continue
filtered.push(session)
}
filteredSessions = filtered
if (selectedIndex >= filteredSessions.length) {
selectedIndex = Math.max(0, filteredSessions.length - 1)
}
}
onSearchTextChanged: updateFilteredSessions()
Connections {
target: MuxService
function onSessionsChanged() {
updateFilteredSessions()
}
}
HyprlandFocusGrab {
id: grab
windows: [muxModal.contentWindow]
active: CompositorService.isHyprland && muxModal.shouldHaveFocus
}
function toggle() {
if (shouldBeVisible) {
hide()
} else {
show()
}
}
function show() {
open()
selectedIndex = -1
searchText = ""
MuxService.refreshSessions()
shouldHaveFocus = true
Qt.callLater(() => {
if (muxPanel && muxPanel.searchField) {
muxPanel.searchField.forceActiveFocus();
}
})
}
function hide() {
close()
selectedIndex = -1
searchText = ""
}
function attachToSession(name) {
MuxService.attachToSession(name)
hide()
}
function renameSession(name) {
inputModal.showWithOptions({
title: I18n.tr("Rename Session"),
message: I18n.tr("Enter a new name for session \"%1\"").arg(name),
initialText: name,
onConfirm: function (newName) {
MuxService.renameSession(name, newName)
}
})
}
function killSession(name) {
confirmModal.showWithOptions({
title: I18n.tr("Kill Session"),
message: I18n.tr("Are you sure you want to kill session \"%1\"?").arg(name),
confirmText: I18n.tr("Kill"),
confirmColor: Theme.primary,
onConfirm: function () {
MuxService.killSession(name)
}
})
}
function createNewSession() {
inputModal.showWithOptions({
title: I18n.tr("New Session"),
message: I18n.tr("Please write a name for your new %1 session").arg(MuxService.displayName),
onConfirm: function (name) {
MuxService.createSession(name)
hide()
}
})
}
function selectNext() {
selectedIndex = Math.min(selectedIndex + 1, filteredSessions.length - 1)
}
function selectPrevious() {
selectedIndex = Math.max(selectedIndex - 1, -1)
}
function activateSelected() {
if (selectedIndex === -1) {
createNewSession()
} else if (selectedIndex >= 0 && selectedIndex < filteredSessions.length) {
attachToSession(filteredSessions[selectedIndex].name)
}
}
visible: false
modalWidth: 600
modalHeight: 600
backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
cornerRadius: Theme.cornerRadius
borderColor: Theme.outlineMedium
borderWidth: 1
enableShadow: true
keepContentLoaded: true
onBackgroundClicked: hide()
Timer {
interval: 3000
running: muxModal.shouldBeVisible
repeat: true
onTriggered: MuxService.refreshSessions()
}
IpcHandler {
function open(): string {
muxModal.show()
return "MUX_OPEN_SUCCESS"
}
function close(): string {
muxModal.hide()
return "MUX_CLOSE_SUCCESS"
}
function toggle(): string {
muxModal.toggle()
return "MUX_TOGGLE_SUCCESS"
}
target: "mux"
}
// Backwards compatibility
IpcHandler {
function open(): string {
muxModal.show()
return "TMUX_OPEN_SUCCESS"
}
function close(): string {
muxModal.hide()
return "TMUX_CLOSE_SUCCESS"
}
function toggle(): string {
muxModal.toggle()
return "TMUX_TOGGLE_SUCCESS"
}
target: "tmux"
}
InputModal {
id: inputModal
onShouldBeVisibleChanged: {
if (shouldBeVisible) {
muxModal.shouldHaveFocus = false;
muxModal.contentWindow.visible = false;
return;
}
if (muxModal.shouldBeVisible) {
muxModal.contentWindow.visible = true;
}
Qt.callLater(function () {
if (!muxModal.shouldBeVisible) {
return;
}
muxModal.shouldHaveFocus = true;
muxModal.modalFocusScope.forceActiveFocus();
if (muxPanel.searchField) {
muxPanel.searchField.forceActiveFocus();
}
});
}
}
ConfirmModal {
id: confirmModal
onShouldBeVisibleChanged: {
if (shouldBeVisible) {
muxModal.shouldHaveFocus = false;
muxModal.contentWindow.visible = false;
return;
}
if (muxModal.shouldBeVisible) {
muxModal.contentWindow.visible = true;
}
Qt.callLater(function () {
if (!muxModal.shouldBeVisible) {
return;
}
muxModal.shouldHaveFocus = true;
muxModal.modalFocusScope.forceActiveFocus();
if (muxPanel.searchField) {
muxPanel.searchField.forceActiveFocus();
}
});
}
}
directContent: Item {
id: muxPanel
clip: false
property alias searchField: searchField
Keys.onPressed: event => {
if ((event.key === Qt.Key_J && (event.modifiers & Qt.ControlModifier)) ||
(event.key === Qt.Key_Down)) {
selectNext()
event.accepted = true
} else if ((event.key === Qt.Key_K && (event.modifiers & Qt.ControlModifier)) ||
(event.key === Qt.Key_Up)) {
selectPrevious()
event.accepted = true
} else if (event.key === Qt.Key_N && (event.modifiers & Qt.ControlModifier)) {
createNewSession()
event.accepted = true
} else if (event.key === Qt.Key_R && (event.modifiers & Qt.ControlModifier)) {
if (MuxService.supportsRename && selectedIndex >= 0 && selectedIndex < filteredSessions.length) {
renameSession(filteredSessions[selectedIndex].name)
}
event.accepted = true
} else if (event.key === Qt.Key_D && (event.modifiers & Qt.ControlModifier)) {
if (selectedIndex >= 0 && selectedIndex < filteredSessions.length) {
killSession(filteredSessions[selectedIndex].name)
}
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
hide()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
activateSelected()
event.accepted = true
}
}
Column {
width: parent.width - Theme.spacingM * 2
height: parent.height - Theme.spacingM * 2
x: Theme.spacingM
y: Theme.spacingM
spacing: Theme.spacingS
// Header
Item {
width: parent.width
height: 40
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("%1 Sessions").arg(MuxService.displayName)
font.pixelSize: Theme.fontSizeLarge + 4
font.weight: Font.Bold
color: Theme.surfaceText
}
StyledText {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("%1 active, %2 filtered").arg(MuxService.sessions.length).arg(muxModal.filteredSessions.length)
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
}
}
// Search field
DankTextField {
id: searchField
width: parent.width
height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
leftIconFocusedColor: Theme.primary
showClearButton: true
font.pixelSize: Theme.fontSizeMedium
placeholderText: I18n.tr("Search sessions...")
keyForwardTargets: [muxPanel]
onTextEdited: {
muxModal.searchText = text
muxModal.selectedIndex = 0
}
}
// New Session Button
Rectangle {
width: parent.width
height: 56
radius: Theme.cornerRadius
color: muxModal.selectedIndex === -1 ? Theme.primaryContainer :
(newMouse.containsMouse ? Theme.surfaceContainerHigh : Theme.surfaceContainer)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingM
Rectangle {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
radius: 20
color: Theme.primaryContainer
DankIcon {
anchors.centerIn: parent
name: "add"
size: Theme.iconSize
color: Theme.primary
}
}
Column {
Layout.fillWidth: true
spacing: 2
StyledText {
text: I18n.tr("New Session")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Create a new %1 session (n)").arg(MuxService.displayName)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
MouseArea {
id: newMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: muxModal.createNewSession()
}
}
// Sessions List
Rectangle {
width: parent.width
height: parent.height - 88 - 48 - shortcutsBar.height - Theme.spacingS * 3
radius: Theme.cornerRadius
color: "transparent"
ScrollView {
anchors.fill: parent
clip: true
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: ScriptModel {
values: muxModal.filteredSessions
}
delegate: Rectangle {
required property var modelData
required property int index
width: parent.width
height: 64
radius: Theme.cornerRadius
color: muxModal.selectedIndex === index ? Theme.primaryContainer :
(sessionMouse.containsMouse ? Theme.surfaceContainerHigh : "transparent")
MouseArea {
id: sessionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: muxModal.attachToSession(modelData.name)
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingM
// Avatar
Rectangle {
Layout.preferredWidth: 40
Layout.preferredHeight: 40
radius: 20
color: modelData.attached ? Theme.primaryContainer : Theme.surfaceContainerHigh
StyledText {
anchors.centerIn: parent
text: modelData.name.charAt(0).toUpperCase()
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: modelData.attached ? Theme.primary : Theme.surfaceText
}
}
// Info
Column {
Layout.fillWidth: true
spacing: 2
StyledText {
text: modelData.name
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
}
StyledText {
text: {
var parts = []
if (modelData.windows !== "N/A")
parts.push(I18n.tr("%1 windows").arg(modelData.windows))
parts.push(modelData.attached ? I18n.tr("attached") : I18n.tr("detached"))
return parts.join(" \u2022 ")
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
// Rename button (tmux only)
Rectangle {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
radius: 18
visible: MuxService.supportsRename
color: renameMouse.containsMouse ? Theme.surfaceContainerHighest : "transparent"
DankIcon {
anchors.centerIn: parent
name: "edit"
size: Theme.iconSizeSmall
color: renameMouse.containsMouse ? Theme.primary : Theme.surfaceVariantText
}
MouseArea {
id: renameMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: muxModal.renameSession(modelData.name)
}
}
// Delete button
Rectangle {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
radius: 18
color: deleteMouse.containsMouse ? Theme.errorContainer : "transparent"
DankIcon {
anchors.centerIn: parent
name: "delete"
size: Theme.iconSizeSmall
color: deleteMouse.containsMouse ? Theme.error : Theme.surfaceVariantText
}
MouseArea {
id: deleteMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
muxModal.killSession(modelData.name)
}
}
}
}
}
}
// Empty state
Item {
width: parent.width
height: muxModal.filteredSessions.length === 0 ? 200 : 0
visible: muxModal.filteredSessions.length === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingM
DankIcon {
name: muxModal.searchText.length > 0 ? "search_off" : "terminal"
size: 48
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: muxModal.searchText.length > 0 ? I18n.tr("No sessions found") : I18n.tr("No active %1 sessions").arg(MuxService.displayName)
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: muxModal.searchText.length > 0 ? I18n.tr("Try a different search") : I18n.tr("Press 'n' or click 'New Session' to create one")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
}
}
// Shortcuts bar
Row {
id: shortcutsBar
width: parent.width
spacing: Theme.spacingM
bottomPadding: Theme.spacingS
Repeater {
model: {
var shortcuts = [
{ key: "↑↓", label: I18n.tr("Navigate") },
{ key: "↵", label: I18n.tr("Attach") },
{ key: "^N", label: I18n.tr("New") },
{ key: "^D", label: I18n.tr("Kill") },
{ key: "Esc", label: I18n.tr("Close") }
]
if (MuxService.supportsRename)
shortcuts.splice(3, 0, { key: "^R", label: I18n.tr("Rename") })
return shortcuts
}
delegate: Row {
required property var modelData
spacing: 4
Rectangle {
width: keyText.width + Theme.spacingS
height: keyText.height + 4
radius: 4
color: Theme.surfaceContainerHighest
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: keyText
anchors.centerIn: parent
text: modelData.key
font.pixelSize: Theme.fontSizeSmall - 1
font.weight: Font.Medium
color: Theme.surfaceVariantText
}
}
StyledText {
text: modelData.label
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
}
}

View File

@@ -473,5 +473,20 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus()); Qt.callLater(() => item.forceActiveFocus());
} }
} }
Loader {
id: muxLoader
anchors.fill: parent
active: root.currentIndex === 32
visible: active
focus: active
sourceComponent: MuxTab {}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
} }
} }

View File

@@ -156,7 +156,7 @@ Rectangle {
{ {
"id": "running_apps", "id": "running_apps",
"text": I18n.tr("Running Apps"), "text": I18n.tr("Running Apps"),
"icon": "apps", "icon": "app_registration",
"tabIndex": 19, "tabIndex": 19,
"hyprlandNiriOnly": true "hyprlandNiriOnly": true
}, },
@@ -237,7 +237,7 @@ Rectangle {
{ {
"id": "system", "id": "system",
"text": I18n.tr("System"), "text": I18n.tr("System"),
"icon": "computer", "icon": "memory",
"collapsedByDefault": true, "collapsedByDefault": true,
"children": [ "children": [
{ {
@@ -260,6 +260,12 @@ Rectangle {
"tabIndex": 8, "tabIndex": 8,
"cupsOnly": true "cupsOnly": true
}, },
{
"id": "multiplexers",
"text": I18n.tr("Multiplexers"),
"icon": "terminal",
"tabIndex": 32
},
{ {
"id": "window_rules", "id": "window_rules",
"text": I18n.tr("Window Rules"), "text": I18n.tr("Window Rules"),

View File

@@ -10,6 +10,36 @@ BasePill {
readonly property MprisPlayer activePlayer: MprisController.activePlayer readonly property MprisPlayer activePlayer: MprisController.activePlayer
readonly property bool playerAvailable: activePlayer !== null readonly property bool playerAvailable: activePlayer !== null
readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer)
readonly property bool _isPlaying: !!activePlayer && activePlayer.playbackState === 1 && !_hoverPreview
property string _stableTitle: ""
property string _stableArtist: ""
Connections {
target: root.activePlayer
function onTrackTitleChanged() {
root._syncMeta();
}
function onTrackArtistChanged() {
root._syncMeta();
}
}
onActivePlayerChanged: _syncMeta()
function _syncMeta() {
if (!activePlayer) {
_stableTitle = "";
_stableArtist = "";
return;
}
if (MprisController.isFirefoxYoutubeHoverPreview(activePlayer))
return;
_stableTitle = activePlayer.trackTitle || "";
_stableArtist = activePlayer.trackArtist || "";
}
readonly property bool __isChromeBrowser: { readonly property bool __isChromeBrowser: {
if (!activePlayer?.identity) if (!activePlayer?.identity)
return false; return false;
@@ -191,15 +221,15 @@ BasePill {
height: 24 height: 24
radius: 12 radius: 12
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
color: activePlayer && activePlayer.playbackState === 1 ? Theme.primary : Theme.primaryHover color: root._isPlaying ? Theme.primary : Theme.primaryHover
visible: root.playerAvailable visible: root.playerAvailable
opacity: activePlayer ? 1 : 0.3 opacity: activePlayer ? 1 : 0.3
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: activePlayer && activePlayer.playbackState === 1 ? "pause" : "play_arrow" name: root._isPlaying ? "pause" : "play_arrow"
size: 14 size: 14
color: activePlayer && activePlayer.playbackState === 1 ? Theme.background : Theme.primary color: root._isPlaying ? Theme.background : Theme.primary
} }
MouseArea { MouseArea {
@@ -258,12 +288,10 @@ BasePill {
readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari") readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari")
property string displayText: { property string displayText: {
if (!activePlayer || !activePlayer.trackTitle) { if (!activePlayer || !root._stableTitle)
return ""; return "";
} const title = isWebMedia ? root._stableTitle : (root._stableTitle || "Unknown Track");
const subtitle = isWebMedia ? (root._stableArtist || cachedIdentity) : (root._stableArtist || "");
const title = isWebMedia ? activePlayer.trackTitle : (activePlayer.trackTitle || "Unknown Track");
const subtitle = isWebMedia ? (activePlayer.trackArtist || cachedIdentity) : (activePlayer.trackArtist || "");
return subtitle.length > 0 ? title + " • " + subtitle : title; return subtitle.length > 0 ? title + " • " + subtitle : title;
} }
@@ -381,15 +409,15 @@ BasePill {
height: 24 height: 24
radius: 12 radius: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: activePlayer && activePlayer.playbackState === 1 ? Theme.primary : Theme.primaryHover color: root._isPlaying ? Theme.primary : Theme.primaryHover
visible: root.playerAvailable visible: root.playerAvailable
opacity: activePlayer ? 1 : 0.3 opacity: activePlayer ? 1 : 0.3
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: activePlayer && activePlayer.playbackState === 1 ? "pause" : "play_arrow" name: root._isPlaying ? "pause" : "play_arrow"
size: 14 size: 14
color: activePlayer && activePlayer.playbackState === 1 ? Theme.background : Theme.primary color: root._isPlaying ? Theme.background : Theme.primary
} }
MouseArea { MouseArea {

View File

@@ -271,7 +271,7 @@ BasePill {
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: { color: {
if (isFocused) { if (isFocused) {
return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.2); return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.45);
} }
return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"; return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
} }
@@ -526,7 +526,7 @@ BasePill {
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: { color: {
if (isFocused) { if (isFocused) {
return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.2); return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.45);
} }
return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"; return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
} }

View File

@@ -17,8 +17,11 @@ BasePill {
enableCursor: false enableCursor: false
property var parentWindow: null property var parentWindow: null
property var widgetData: null
property string section: "right"
property bool isAtBottom: false property bool isAtBottom: false
property bool isAutoHideBar: false property bool isAutoHideBar: false
property bool useOverflowPopup: !widgetData?.trayUseInlineExpansion
readonly property var hiddenTrayIds: { readonly property var hiddenTrayIds: {
const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || ""; const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || "";
return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : []; return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : [];
@@ -41,6 +44,54 @@ BasePill {
return `${id}::${tooltipTitle}`; return `${id}::${tooltipTitle}`;
} }
function trayIconSourceFor(trayItem) {
let icon = trayItem && trayItem.icon;
if (typeof icon === 'string' || icon instanceof String) {
if (icon === "")
return "";
if (icon.includes("?path=")) {
const split = icon.split("?path=");
if (split.length !== 2)
return icon;
const name = split[0];
const path = split[1];
let fileName = name.substring(name.lastIndexOf("/") + 1);
if (fileName.startsWith("dropboxstatus")) {
fileName = `hicolor/16x16/status/${fileName}`;
}
return `file://${path}/${fileName}`;
}
if (icon.startsWith("/") && !icon.startsWith("file://"))
return `file://${icon}`;
return icon;
}
return "";
}
function activateInlineTrayItem(trayItem, anchorItem) {
if (!trayItem)
return;
if (!trayItem.onlyMenu) {
trayItem.activate();
return;
}
if (!trayItem.hasMenu)
return;
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
}
function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) {
if (!trayItem) {
return;
}
if (!trayItem.hasMenu) {
const gp = areaItem.mapToGlobal(mouse.x, mouse.y);
root.callContextMenuFallback(trayItem.id, Math.round(gp.x), Math.round(gp.y));
return;
}
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
}
// ! TODO - replace with either native dbus client (like plugins use) or just a DMS cli or something // ! TODO - replace with either native dbus client (like plugins use) or just a DMS cli or something
function callContextMenuFallback(trayItemId, globalX, globalY) { function callContextMenuFallback(trayItemId, globalX, globalY) {
const script = ['ITEMS=$(dbus-send --session --print-reply --dest=org.kde.StatusNotifierWatcher /StatusNotifierWatcher org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierWatcher string:RegisteredStatusNotifierItems 2>/dev/null)', 'while IFS= read -r line; do', ' line="${line#*\\\"}"', ' line="${line%\\\"*}"', ' [ -z "$line" ] && continue', ' BUS="${line%%/*}"', ' OBJ="/${line#*/}"', ' ID=$(dbus-send --session --print-reply --dest="$BUS" "$OBJ" org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierItem string:Id 2>/dev/null | grep -oP "(?<=\\\")(.*?)(?=\\\")" | tail -1)', ' if [ "$ID" = "$1" ]; then', ' dbus-send --session --type=method_call --dest="$BUS" "$OBJ" org.kde.StatusNotifierItem.ContextMenu int32:"$2" int32:"$3"', ' exit 0', ' fi', 'done <<< "$ITEMS"',].join("\n"); const script = ['ITEMS=$(dbus-send --session --print-reply --dest=org.kde.StatusNotifierWatcher /StatusNotifierWatcher org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierWatcher string:RegisteredStatusNotifierItems 2>/dev/null)', 'while IFS= read -r line; do', ' line="${line#*\\\"}"', ' line="${line%\\\"*}"', ' [ -z "$line" ] && continue', ' BUS="${line%%/*}"', ' OBJ="/${line#*/}"', ' ID=$(dbus-send --session --print-reply --dest="$BUS" "$OBJ" org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierItem string:Id 2>/dev/null | grep -oP "(?<=\\\")(.*?)(?=\\\")" | tail -1)', ' if [ "$ID" = "$1" ]; then', ' dbus-send --session --type=method_call --dest="$BUS" "$OBJ" org.kde.StatusNotifierItem.ContextMenu int32:"$2" int32:"$3"', ' exit 0', ' fi', 'done <<< "$ITEMS"',].join("\n");
@@ -164,6 +215,7 @@ BasePill {
property int dropTargetIndex: -1 property int dropTargetIndex: -1
property bool suppressShiftAnimation: false property bool suppressShiftAnimation: false
readonly property bool hasHiddenItems: allTrayItems.length > mainBarItems.length readonly property bool hasHiddenItems: allTrayItems.length > mainBarItems.length
readonly property bool inlineExpanded: hasHiddenItems && !useOverflowPopup && menuOpen
visible: allTrayItems.length > 0 visible: allTrayItems.length > 0
opacity: allTrayItems.length > 0 ? 1 : 0 opacity: allTrayItems.length > 0 ? 1 : 0

View File

@@ -29,12 +29,6 @@ Singleton {
property bool use24HourClock: true property bool use24HourClock: true
property bool showSeconds: false property bool showSeconds: false
property bool padHours12Hour: false property bool padHours12Hour: false
property bool greeterUse24HourClock: true
property bool greeterShowSeconds: false
property bool greeterPadHours12Hour: false
property string greeterLockDateFormat: ""
property string greeterFontFamily: ""
property string greeterWallpaperFillMode: ""
property bool useFahrenheit: false property bool useFahrenheit: false
property bool nightModeEnabled: false property bool nightModeEnabled: false
property string weatherLocation: "New York, NY" property string weatherLocation: "New York, NY"
@@ -87,12 +81,6 @@ Singleton {
use24HourClock = settings.use24HourClock !== undefined ? settings.use24HourClock : true; use24HourClock = settings.use24HourClock !== undefined ? settings.use24HourClock : true;
showSeconds = settings.showSeconds !== undefined ? settings.showSeconds : false; showSeconds = settings.showSeconds !== undefined ? settings.showSeconds : false;
padHours12Hour = settings.padHours12Hour !== undefined ? settings.padHours12Hour : false; padHours12Hour = settings.padHours12Hour !== undefined ? settings.padHours12Hour : false;
greeterUse24HourClock = settings.greeterUse24HourClock !== undefined ? settings.greeterUse24HourClock : use24HourClock;
greeterShowSeconds = settings.greeterShowSeconds !== undefined ? settings.greeterShowSeconds : showSeconds;
greeterPadHours12Hour = settings.greeterPadHours12Hour !== undefined ? settings.greeterPadHours12Hour : padHours12Hour;
greeterLockDateFormat = settings.greeterLockDateFormat !== undefined ? settings.greeterLockDateFormat : "";
greeterFontFamily = settings.greeterFontFamily !== undefined ? settings.greeterFontFamily : "";
greeterWallpaperFillMode = settings.greeterWallpaperFillMode !== undefined ? settings.greeterWallpaperFillMode : "";
useFahrenheit = settings.useFahrenheit !== undefined ? settings.useFahrenheit : false; useFahrenheit = settings.useFahrenheit !== undefined ? settings.useFahrenheit : false;
nightModeEnabled = settings.nightModeEnabled !== undefined ? settings.nightModeEnabled : false; nightModeEnabled = settings.nightModeEnabled !== undefined ? settings.nightModeEnabled : false;
weatherLocation = settings.weatherLocation !== undefined ? settings.weatherLocation : "New York, NY"; weatherLocation = settings.weatherLocation !== undefined ? settings.weatherLocation : "New York, NY";
@@ -149,27 +137,23 @@ Singleton {
} }
function getEffectiveTimeFormat() { function getEffectiveTimeFormat() {
const use24 = greeterUse24HourClock; if (use24HourClock)
const secs = greeterShowSeconds; return showSeconds ? "hh:mm:ss" : "hh:mm";
const pad = greeterPadHours12Hour; if (padHours12Hour)
if (use24) return showSeconds ? "hh:mm:ss AP" : "hh:mm AP";
return secs ? "hh:mm:ss" : "hh:mm"; return showSeconds ? "h:mm:ss AP" : "h:mm AP";
if (pad)
return secs ? "hh:mm:ss AP" : "hh:mm AP";
return secs ? "h:mm:ss AP" : "h:mm AP";
} }
function getEffectiveLockDateFormat() { function getEffectiveLockDateFormat() {
const fmt = (greeterLockDateFormat !== undefined && greeterLockDateFormat !== "") ? greeterLockDateFormat : lockDateFormat; return lockDateFormat && lockDateFormat.length > 0 ? lockDateFormat : Locale.LongFormat;
return fmt && fmt.length > 0 ? fmt : Locale.LongFormat;
} }
function getEffectiveWallpaperFillMode() { function getEffectiveWallpaperFillMode() {
return (greeterWallpaperFillMode && greeterWallpaperFillMode !== "") ? greeterWallpaperFillMode : wallpaperFillMode; return wallpaperFillMode;
} }
function getEffectiveFontFamily() { function getEffectiveFontFamily() {
return (greeterFontFamily && greeterFontFamily !== "") ? greeterFontFamily : fontFamily; return fontFamily;
} }
function getFilteredScreens(componentId) { function getFilteredScreens(componentId) {

View File

@@ -147,6 +147,13 @@ Scope {
} }
} }
Pam {
id: sharedPam
lockSecured: root.shouldLock
buffer: root.sharedPasswordBuffer
onUnlockRequested: root.unlock()
}
WlSessionLock { WlSessionLock {
id: sessionLock id: sessionLock
@@ -170,6 +177,7 @@ Scope {
anchors.fill: parent anchors.fill: parent
visible: lockSurface.isActiveScreen visible: lockSurface.isActiveScreen
lock: sessionLock lock: sessionLock
pam: sharedPam
sharedPasswordBuffer: root.sharedPasswordBuffer sharedPasswordBuffer: root.sharedPasswordBuffer
screenName: lockSurface.currentScreenName screenName: lockSurface.currentScreenName
isLocked: shouldLock isLocked: shouldLock

View File

@@ -23,6 +23,7 @@ Item {
property string passwordBuffer: "" property string passwordBuffer: ""
property bool demoMode: false property bool demoMode: false
property var pam: demoPam
property string screenName: "" property string screenName: ""
property bool unlocking: false property bool unlocking: false
property string pamState: "" property string pamState: ""
@@ -52,20 +53,18 @@ Item {
return I18n.tr("Touch your security key..."); return I18n.tr("Touch your security key...");
if (pam.lockMessage && pam.lockMessage.length > 0) if (pam.lockMessage && pam.lockMessage.length > 0)
return pam.lockMessage; return pam.lockMessage;
if (pam.fprintState === "error") {
const detail = (pam.fprint.message || "").trim();
return detail.length > 0 ? I18n.tr("Fingerprint error: %1").arg(detail) : I18n.tr("Fingerprint error");
}
if (pam.fprintState === "max")
return I18n.tr("Maximum fingerprint attempts reached. Please use password.");
if (pam.fprintState === "fail")
return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries);
if (root.pamState === "error") if (root.pamState === "error")
return I18n.tr("Authentication error - try again"); return I18n.tr("Authentication error - try again");
if (root.pamState === "max") if (root.pamState === "max")
return I18n.tr("Too many attempts - locked out"); return I18n.tr("Too many attempts - locked out");
if (root.pamState === "fail") if (root.pamState === "fail")
return I18n.tr("Incorrect password - try again"); return I18n.tr("Incorrect password - try again");
if (pam.fprintState === "error")
return I18n.tr("Fingerprint error");
if (pam.fprintState === "max")
return I18n.tr("Maximum fingerprint attempts reached. Please use password.");
if (pam.fprintState === "fail")
return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries);
return ""; return "";
} }
@@ -745,13 +744,6 @@ Item {
easing.type: Theme.standardEasing easing.type: Theme.standardEasing
} }
} }
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
} }
} }
@@ -1639,49 +1631,46 @@ Item {
} }
Pam { Pam {
id: pam id: demoPam
lockSecured: !demoMode lockSecured: false
onUnlockRequested: { }
Connections {
target: root.pam
function onUnlockRequested() {
root.unlocking = true; root.unlocking = true;
lockerReadyArmed = false; lockerReadyArmed = false;
passwordField.text = ""; passwordField.text = "";
root.passwordBuffer = ""; root.passwordBuffer = "";
root.unlockRequested(); root.unlockRequested();
} }
onStateChanged: {
root.pamState = state;
if (state !== "") {
root.unlocking = false;
placeholderDelay.restart();
passwordField.text = "";
root.passwordBuffer = "";
}
}
onU2fPendingChanged: {
if (u2fPending) {
passwordField.text = "";
root.passwordBuffer = "";
if (keyboardController.isKeyboardActive)
keyboardController.hide();
}
}
}
Connections { function onStateChanged() {
target: pam root.pamState = root.pam.state;
if (root.pam.state === "")
return;
root.unlocking = false;
placeholderDelay.restart();
passwordField.text = "";
root.passwordBuffer = "";
}
function onU2fPendingChanged() {
if (!root.pam.u2fPending)
return;
passwordField.text = "";
root.passwordBuffer = "";
if (keyboardController.isKeyboardActive)
keyboardController.hide();
}
function onUnlockInProgressChanged() { function onUnlockInProgressChanged() {
if (!pam.unlockInProgress && root.unlocking) if (!root.pam.unlockInProgress && root.unlocking)
root.unlocking = false; root.unlocking = false;
} }
} }
Binding {
target: pam
property: "buffer"
value: root.passwordBuffer
}
Timer { Timer {
id: placeholderDelay id: placeholderDelay

View File

@@ -7,6 +7,7 @@ Rectangle {
id: root id: root
required property WlSessionLock lock required property WlSessionLock lock
required property var pam
required property string sharedPasswordBuffer required property string sharedPasswordBuffer
required property string screenName required property string screenName
required property bool isLocked required property bool isLocked
@@ -21,6 +22,7 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
demoMode: false demoMode: false
pam: root.pam
passwordBuffer: root.sharedPasswordBuffer passwordBuffer: root.sharedPasswordBuffer
screenName: root.screenName screenName: root.screenName
onUnlockRequested: root.unlockRequested() onUnlockRequested: root.unlockRequested()

View File

@@ -179,6 +179,8 @@ Scope {
abort(); abort();
return; return;
} }
if (active)
return;
tries = 0; tries = 0;
errorTries = 0; errorTries = 0;
@@ -192,22 +194,23 @@ Scope {
if (!available) if (!available)
return; return;
if (res === PamResult.Success) { switch (res) {
case PamResult.Success:
if (!root.unlockInProgress) { if (!root.unlockInProgress) {
passwd.abort(); passwd.abort();
root.proceedAfterPrimaryAuth(); root.proceedAfterPrimaryAuth();
} }
return; return;
} case PamResult.Error:
if (res === PamResult.Error) {
root.fprintState = "error";
errorTries++; errorTries++;
if (errorTries < 5) { if (errorTries < 200) {
abort(); abort();
errorRetry.restart(); errorRetry.restart();
return;
} }
} else if (res === PamResult.MaxTries) { abort();
return;
case PamResult.MaxTries:
tries++; tries++;
if (tries < SettingsData.maxFprintTries) { if (tries < SettingsData.maxFprintTries) {
root.fprintState = "fail"; root.fprintState = "fail";
@@ -216,6 +219,9 @@ Scope {
root.fprintState = "max"; root.fprintState = "max";
abort(); abort();
} }
break;
default:
return;
} }
root.flashMsg(); root.flashMsg();
@@ -294,7 +300,7 @@ Scope {
Timer { Timer {
id: errorRetry id: errorRetry
interval: 800 interval: 1500
onTriggered: fprint.start() onTriggered: fprint.start()
} }
@@ -346,26 +352,22 @@ Scope {
id: fprintStateReset id: fprintStateReset
interval: 4000 interval: 4000
onTriggered: { onTriggered: root.fprintState = ""
root.fprintState = "";
fprint.errorTries = 0;
}
} }
onLockSecuredChanged: { onLockSecuredChanged: {
if (lockSecured) { if (!lockSecured) {
SettingsData.refreshAuthAvailability();
root.state = "";
root.fprintState = "";
root.u2fState = "";
root.u2fPending = false;
root.lockMessage = "";
root.resetAuthFlows();
fprint.checkAvail();
u2f.checkAvail();
} else {
root.resetAuthFlows(); root.resetAuthFlows();
return;
} }
root.state = "";
root.fprintState = "";
root.u2fState = "";
root.u2fPending = false;
root.lockMessage = "";
root.resetAuthFlows();
fprint.checkAvail();
u2f.checkAvail();
} }
Connections { Connections {

View File

@@ -674,8 +674,10 @@ Rectangle {
onEntered: parent.isHovered = true onEntered: parent.isHovered = true
onExited: parent.isHovered = false onExited: parent.isHovered = false
onClicked: { onClicked: {
if (modelData && modelData.invoke) if (modelData && modelData.invoke) {
modelData.invoke(); modelData.invoke();
PopoutService.closeNotificationCenter();
}
} }
} }
} }
@@ -813,6 +815,7 @@ Rectangle {
onClicked: { onClicked: {
if (modelData && modelData.invoke) { if (modelData && modelData.invoke) {
modelData.invoke(); modelData.invoke();
PopoutService.closeNotificationCenter();
} }
} }
} }

View File

@@ -97,6 +97,8 @@ DankPopout {
onDprChanged: updateStablePopupHeight() onDprChanged: updateStablePopupHeight()
onShouldBeVisibleChanged: { onShouldBeVisibleChanged: {
notificationHistoryVisible = shouldBeVisible;
if (shouldBeVisible) { if (shouldBeVisible) {
NotificationService.onOverlayOpen(); NotificationService.onOverlayOpen();
updateStablePopupHeight(); updateStablePopupHeight();

View File

@@ -47,6 +47,9 @@ DankOSD {
} }
property bool _pendingShow: false property bool _pendingShow: false
property string _displayTitle: ""
property string _displayArtist: ""
property string _displayAlbum: ""
Timer { Timer {
id: iconDebounce id: iconDebounce
@@ -105,6 +108,12 @@ DankOSD {
return; return;
if (!SettingsData.osdMediaPlaybackEnabled) if (!SettingsData.osdMediaPlaybackEnabled)
return; return;
if (MprisController.isFirefoxYoutubeHoverPreview(player))
return;
root._displayTitle = player.trackTitle || "";
root._displayArtist = player.trackArtist || "";
root._displayAlbum = player.trackAlbum || "";
root.updatePlaybackIcon(); root.updatePlaybackIcon();
TrackArtService.loadArtwork(player.trackArtUrl); TrackArtService.loadArtwork(player.trackArtUrl);
@@ -254,7 +263,7 @@ DankOSD {
StyledText { StyledText {
id: topText id: topText
width: parent.width width: parent.width
text: player ? `${player.trackTitle || I18n.tr("Unknown Title")}` : "" text: player ? (root._displayTitle || I18n.tr("Unknown Title")) : ""
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium font.weight: Font.Medium
color: Theme.surfaceText color: Theme.surfaceText
@@ -265,7 +274,7 @@ DankOSD {
StyledText { StyledText {
id: bottomText id: bottomText
width: parent.width width: parent.width
text: player ? ((player.trackArtist || I18n.tr("Unknown Artist")) + (player.trackAlbum ? ` ${player.trackAlbum}` : "")) : "" text: player ? ((root._displayArtist || I18n.tr("Unknown Artist")) + (root._displayAlbum ? ` ${root._displayAlbum}` : "")) : ""
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Light font.weight: Font.Light
color: Theme.surfaceText color: Theme.surfaceText

View File

@@ -211,6 +211,7 @@ Item {
property real minWidth: contentLoader.item?.minWidth ?? 100 property real minWidth: contentLoader.item?.minWidth ?? 100
property real minHeight: contentLoader.item?.minHeight ?? 100 property real minHeight: contentLoader.item?.minHeight ?? 100
property bool forceSquare: contentLoader.item?.forceSquare ?? false property bool forceSquare: contentLoader.item?.forceSquare ?? false
property bool acceptsKeyboardFocus: contentLoader.item?.acceptsKeyboardFocus ?? false
property bool isInteracting: dragArea.pressed || resizeArea.pressed property bool isInteracting: dragArea.pressed || resizeArea.pressed
property var _gridSettingsTrigger: SettingsData.desktopWidgetGridSettings property var _gridSettingsTrigger: SettingsData.desktopWidgetGridSettings
@@ -299,11 +300,14 @@ Item {
} }
WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (!root.isInteracting) if (root.isInteracting) {
return WlrKeyboardFocus.None; if (CompositorService.useHyprlandFocusGrab)
if (CompositorService.useHyprlandFocusGrab) return WlrKeyboardFocus.OnDemand;
return WlrKeyboardFocus.Exclusive;
}
if (root.acceptsKeyboardFocus)
return WlrKeyboardFocus.OnDemand; return WlrKeyboardFocus.OnDemand;
return WlrKeyboardFocus.Exclusive; return WlrKeyboardFocus.None;
} }
HyprlandFocusGrab { HyprlandFocusGrab {

View File

@@ -0,0 +1,112 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
readonly property var muxTypeOptions: [
"tmux",
"zellij"
]
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: mainColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: mainColumn
topPadding: 4
width: Math.min(550, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
SettingsCard {
tab: "mux"
tags: ["mux", "multiplexer", "tmux", "zellij", "type"]
title: I18n.tr("Multiplexer")
iconName: "terminal"
SettingsDropdownRow {
tab: "mux"
tags: ["mux", "multiplexer", "tmux", "zellij", "type", "backend"]
settingKey: "muxType"
text: I18n.tr("Multiplexer Type")
description: I18n.tr("Terminal multiplexer backend to use")
options: root.muxTypeOptions
currentValue: SettingsData.muxType
onValueChanged: value => SettingsData.set("muxType", value)
}
}
SettingsCard {
tab: "mux"
tags: ["mux", "terminal", "custom", "command", "script"]
title: I18n.tr("Terminal")
iconName: "desktop_windows"
SettingsToggleRow {
tab: "mux"
tags: ["mux", "custom", "command", "override"]
settingKey: "muxUseCustomCommand"
text: I18n.tr("Use Custom Command")
description: I18n.tr("Override terminal with a custom command or script")
checked: SettingsData.muxUseCustomCommand
onToggled: checked => SettingsData.set("muxUseCustomCommand", checked)
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
visible: SettingsData.muxUseCustomCommand
StyledText {
width: parent.width
text: I18n.tr("The custom command used when attaching to sessions (receives the session name as the first argument)")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.muxCustomCommand
placeholderText: I18n.tr("Enter command or script path")
onTextEdited: SettingsData.set("muxCustomCommand", text)
}
}
}
SettingsCard {
tab: "mux"
tags: ["mux", "session", "filter", "exclude", "hide"]
title: I18n.tr("Session Filter")
iconName: "filter_list"
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Comma-separated list of session names to hide. Wrap in slashes for regex (e.g., /^_.*/).")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.muxSessionFilter
placeholderText: I18n.tr("e.g., scratch, /^tmp_.*/, build")
onTextEdited: SettingsData.set("muxSessionFilter", text)
}
}
}
}
}
}

View File

@@ -68,6 +68,20 @@ Scope {
hideSpotlight(); hideSpotlight();
} }
onIsClosingChanged: {
if (!isClosing) {
closeTimer.stop();
return;
}
closeTimer.restart();
}
Timer {
id: closeTimer
interval: Theme.expressiveDurations.fast
onTriggered: niriOverviewScope.resetState()
}
Loader { Loader {
id: niriOverlayLoader id: niriOverlayLoader
active: overlayActive || isClosing active: overlayActive || isClosing
@@ -128,7 +142,7 @@ Scope {
WindowBlur { WindowBlur {
targetWindow: overlayWindow targetWindow: overlayWindow
readonly property real s: Math.min(1, spotlightContainer.scale) readonly property real s: Math.min(1, spotlightContainer.scale)
readonly property bool active: spotlightContainer.visible && spotlightContainer.opacity > 0 readonly property bool active: overlayWindow.shouldShowSpotlight && spotlightContainer.opacity > 0
blurX: spotlightContainer.x + spotlightContainer.width * (1 - s) * 0.5 blurX: spotlightContainer.x + spotlightContainer.width * (1 - s) * 0.5
blurY: spotlightContainer.y + spotlightContainer.height * (1 - s) * 0.5 blurY: spotlightContainer.y + spotlightContainer.height * (1 - s) * 0.5
blurWidth: active ? spotlightContainer.width * s : 0 blurWidth: active ? spotlightContainer.width * s : 0
@@ -256,16 +270,10 @@ Scope {
layer.textureSize: layer.enabled ? Qt.size(Math.round(width * overlayWindow.dpr), Math.round(height * overlayWindow.dpr)) : Qt.size(0, 0) layer.textureSize: layer.enabled ? Qt.size(Math.round(width * overlayWindow.dpr), Math.round(height * overlayWindow.dpr)) : Qt.size(0, 0)
Behavior on scale { Behavior on scale {
id: scaleAnimation
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.fast duration: Theme.expressiveDurations.fast
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel
onRunningChanged: {
if (running || !spotlightContainer.animatingOut)
return;
niriOverviewScope.resetState();
}
} }
} }

View File

@@ -71,6 +71,36 @@ Singleton {
return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream && !hidden.includes(node.name)); return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream && !hidden.includes(node.name));
} }
// Resolve a PwNode by name from the live typed list and assign it as the
// default sink. Going through Pipewire.nodes.values directly (no .filter
// / spread / .sort / property var) avoids QML type erasure to QObject*,
// which newer quickshell rejects when assigning to preferredDefaultAudioSink.
function setDefaultSinkByName(name) {
if (!name)
return false;
for (let i = 0; i < Pipewire.nodes.values.length; i++) {
const node = Pipewire.nodes.values[i];
if (node && node.name === name && node.audio && node.isSink && !node.isStream) {
Pipewire.preferredDefaultAudioSink = node;
return true;
}
}
return false;
}
function setDefaultSourceByName(name) {
if (!name)
return false;
for (let i = 0; i < Pipewire.nodes.values.length; i++) {
const node = Pipewire.nodes.values[i];
if (node && node.name === name && node.audio && !node.isSink && !node.isStream) {
Pipewire.preferredDefaultAudioSource = node;
return true;
}
}
return false;
}
function cycleAudioOutput() { function cycleAudioOutput() {
const sinks = getAvailableSinks(); const sinks = getAvailableSinks();
if (sinks.length < 2) if (sinks.length < 2)
@@ -80,7 +110,8 @@ Singleton {
const currentIndex = sinks.findIndex(s => s.name === currentName); const currentIndex = sinks.findIndex(s => s.name === currentName);
const nextIndex = (currentIndex + 1) % sinks.length; const nextIndex = (currentIndex + 1) % sinks.length;
const nextSink = sinks[nextIndex]; const nextSink = sinks[nextIndex];
Pipewire.preferredDefaultAudioSink = nextSink; if (!setDefaultSinkByName(nextSink.name))
Pipewire.preferredDefaultAudioSink = nextSink;
const name = displayName(nextSink); const name = displayName(nextSink);
audioOutputCycled(name, sinkIcon(nextSink)); audioOutputCycled(name, sinkIcon(nextSink));
return name; return name;

View File

@@ -10,4 +10,14 @@ Singleton {
readonly property list<MprisPlayer> availablePlayers: Mpris.players.values readonly property list<MprisPlayer> availablePlayers: Mpris.players.values
property MprisPlayer activePlayer: availablePlayers.find(p => p.isPlaying) ?? availablePlayers.find(p => p.canControl && p.canPlay) ?? null property MprisPlayer activePlayer: availablePlayers.find(p => p.isPlaying) ?? availablePlayers.find(p => p.canControl && p.canPlay) ?? null
function isFirefoxYoutubeHoverPreview(player: MprisPlayer): bool {
if (!player)
return false;
const id = (player.identity || "").toLowerCase();
if (!id.includes("firefox"))
return false;
const url = (player.metadata?.["xesam:url"] || "").toString();
return /^https?:\/\/(www\.)?youtube\.com\/?($|\?|#)/i.test(url);
}
} }

View File

@@ -0,0 +1,230 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
property var sessions: []
property bool loading: false
property bool tmuxAvailable: false
property bool zellijAvailable: false
readonly property bool currentMuxAvailable: muxType === "zellij" ? zellijAvailable : tmuxAvailable
readonly property string muxType: SettingsData.muxType
readonly property string displayName: muxType === "zellij" ? "Zellij" : "Tmux"
readonly property var terminalFlags: ({
"ghostty": ["-e"],
"kitty": ["-e"],
"alacritty": ["-e"],
"foot": [],
"wezterm": ["start", "--"],
"gnome-terminal": ["--"],
"xterm": ["-e"],
"konsole": ["-e"],
"st": ["-e"],
"terminator": ["-e"],
"xfce4-terminal": ["-e"]
})
function getTerminalFlag(terminal) {
return terminalFlags[terminal] ?? ["-e"]
}
readonly property string terminal: Quickshell.env("TERMINAL") || "ghostty"
function _terminalPrefix() {
return [terminal].concat(getTerminalFlag(terminal))
}
Process {
id: tmuxCheckProcess
command: ["which", "tmux"]
running: false
onExited: (code) => { root.tmuxAvailable = (code === 0) }
}
Process {
id: zellijCheckProcess
command: ["which", "zellij"]
running: false
onExited: (code) => { root.zellijAvailable = (code === 0) }
}
function checkAvailability() {
tmuxCheckProcess.running = true
zellijCheckProcess.running = true
}
Component.onCompleted: checkAvailability()
Process {
id: listProcess
running: false
stdout: StdioCollector {
onStreamFinished: {
try {
if (root.muxType === "zellij")
root._parseZellijSessions(text)
else
root._parseTmuxSessions(text)
} catch (e) {
console.error("[MuxService] Error parsing sessions:", e)
root.sessions = []
}
root.loading = false
}
}
stderr: SplitParser {
onRead: (line) => {
if (line.trim())
console.error("[MuxService] stderr:", line)
}
}
onExited: (code) => {
if (code !== 0 && code !== 1) {
console.warn("[MuxService] Process exited with code:", code)
root.sessions = []
}
root.loading = false
}
}
function refreshSessions() {
if (!root.currentMuxAvailable) {
root.sessions = []
return
}
root.loading = true
if (listProcess.running)
listProcess.running = false
if (root.muxType === "zellij")
listProcess.command = ["zellij", "list-sessions", "--no-formatting"]
else
listProcess.command = ["tmux", "list-sessions", "-F", "#{session_name}|#{session_windows}|#{session_attached}"]
Qt.callLater(function () {
listProcess.running = true
})
}
function _isSessionExcluded(name) {
var filter = SettingsData.muxSessionFilter.trim()
if (filter.length === 0)
return false
var parts = filter.split(",")
for (var i = 0; i < parts.length; i++) {
var pattern = parts[i].trim()
if (pattern.length === 0)
continue
if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) {
try {
var re = new RegExp(pattern.slice(1, -1))
if (re.test(name))
return true
} catch (e) {}
} else {
if (name.toLowerCase() === pattern.toLowerCase())
return true
}
}
return false
}
function _parseTmuxSessions(output) {
var sessionList = []
var lines = output.trim().split('\n')
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (line.length === 0)
continue
var parts = line.split('|')
if (parts.length >= 3 && !_isSessionExcluded(parts[0])) {
sessionList.push({
name: parts[0],
windows: parts[1],
attached: parts[2] === "1"
})
}
}
root.sessions = sessionList
}
function _parseZellijSessions(output) {
var sessionList = []
var lines = output.trim().split('\n')
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (line.length === 0)
continue
var exited = line.includes("(EXITED")
var bracketIdx = line.indexOf(" [")
var name = (bracketIdx > 0 ? line.substring(0, bracketIdx) : line).trim()
if (!_isSessionExcluded(name)) {
sessionList.push({
name: name,
windows: "N/A",
attached: !exited
})
}
}
root.sessions = sessionList
}
function attachToSession(name) {
if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) {
Quickshell.execDetached([SettingsData.muxCustomCommand, name])
} else if (root.muxType === "zellij") {
Quickshell.execDetached(_terminalPrefix().concat(["zellij", "attach", name]))
} else {
Quickshell.execDetached(_terminalPrefix().concat(["tmux", "attach", "-t", name]))
}
}
function createSession(name) {
if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) {
Quickshell.execDetached([SettingsData.muxCustomCommand, name])
} else if (root.muxType === "zellij") {
Quickshell.execDetached(_terminalPrefix().concat(["zellij", "-s", name]))
} else {
Quickshell.execDetached(_terminalPrefix().concat(["tmux", "new-session", "-s", name]))
}
}
readonly property bool supportsRename: muxType !== "zellij"
function renameSession(oldName, newName) {
if (root.muxType === "zellij")
return
Quickshell.execDetached(["tmux", "rename-session", "-t", oldName, newName])
Qt.callLater(refreshSessions)
}
function killSession(name) {
if (root.muxType === "zellij") {
Quickshell.execDetached(["zellij", "kill-session", name])
} else {
Quickshell.execDetached(["tmux", "kill-session", "-t", name])
}
Qt.callLater(refreshSessions)
}
}

View File

@@ -1 +1 @@
v1.4.4 v1.4.6

View File

@@ -161,6 +161,13 @@ StyledRect {
if (root.keyForwardTargets[i]) if (root.keyForwardTargets[i])
root.keyForwardTargets[i].Keys.pressed(event); root.keyForwardTargets[i].Keys.pressed(event);
} }
return;
}
if ((event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)) && root.keyForwardTargets.length > 0) {
for (var i = 0; i < root.keyForwardTargets.length; i++) {
if (root.keyForwardTargets[i])
root.keyForwardTargets[i].Keys.pressed(event);
}
} }
} }

View File

@@ -1,3 +1,3 @@
#%PAM-1.0 #%PAM-1.0
auth required pam_fprintd.so max-tries=1 timeout=5 auth required pam_fprintd.so max-tries=5

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff