mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-02 18:42:06 -04:00
Compare commits
47 Commits
frame
...
7ced91ede1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ced91ede1 | ||
|
|
c6e8067a22 | ||
|
|
d7fb75f7f9 | ||
|
|
cf0fa7da6b | ||
|
|
787d213722 | ||
|
|
2138fbf8b7 | ||
|
|
722b3fd1e8 | ||
|
|
2728296cbd | ||
|
|
fe1fd92953 | ||
|
|
0ab9b1e4e9 | ||
|
|
6d0953de68 | ||
|
|
bc6bbdbe9d | ||
|
|
eff728fdf5 | ||
|
|
8d415e9568 | ||
|
|
e6ed6a1cc2 | ||
|
|
ca18174da5 | ||
|
|
976b231b93 | ||
|
|
3d75a51378 | ||
|
|
dc4b1529e6 | ||
|
|
f61438e11f | ||
|
|
8f78163941 | ||
|
|
f894d338fc | ||
|
|
f2df53afcd | ||
|
|
4179fcee83 | ||
|
|
a0c9af1ee7 | ||
|
|
049266271a | ||
|
|
0eabda3164 | ||
|
|
32c063aab8 | ||
|
|
37f92677cf | ||
|
|
13e8130858 | ||
|
|
f6e590a518 | ||
|
|
3194fc3fbe | ||
|
|
3318864ece | ||
|
|
e224417593 | ||
|
|
3f7f6c5d2c | ||
|
|
0b88055742 | ||
|
|
2b0826e397 | ||
|
|
7db04c9660 | ||
|
|
14d1e1d985 | ||
|
|
903ab1e61d | ||
|
|
5982655539 | ||
|
|
1021a210cf | ||
|
|
e34edb15bb | ||
|
|
61ee5f4336 | ||
|
|
ce2a92ec27 | ||
|
|
66ce79b9bf | ||
|
|
30dd640314 |
@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
||||
[](https://aur.archlinux.org/packages/dms-shell-bin)
|
||||
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
||||
[](https://ko-fi.com/danklinux)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
||||
Command-line interface and daemon for shell management and system control.
|
||||
|
||||
**dankinstall**
|
||||
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
|
||||
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||
|
||||
## System Integration
|
||||
|
||||
@@ -147,10 +147,50 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
||||
|
||||
## Installation via dankinstall
|
||||
|
||||
**Interactive (TUI):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.danklinux.com | sh
|
||||
```
|
||||
|
||||
**Headless (unattended):**
|
||||
|
||||
Headless mode requires cached sudo credentials. Run `sudo -v` first:
|
||||
|
||||
```bash
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
|
||||
```
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
|
||||
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
|
||||
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
|
||||
| `--exclude-deps <name,...>` | | Skip specific dependencies |
|
||||
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
|
||||
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
|
||||
| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
|
||||
|
||||
Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
|
||||
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
|
||||
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
|
||||
|
||||
When no flags are provided, `dankinstall` launches the interactive TUI.
|
||||
|
||||
### Headless mode validation rules
|
||||
|
||||
Headless mode activates when `--compositor` or `--term` is provided.
|
||||
|
||||
- Both `--compositor` and `--term` are required; providing only one results in an error.
|
||||
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
|
||||
- Positional arguments are not accepted.
|
||||
|
||||
### Log file location
|
||||
|
||||
`dankinstall` writes logs to `/tmp` by default.
|
||||
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
|
||||
|
||||
## Supported Distributions
|
||||
|
||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
||||
|
||||
@@ -3,20 +3,152 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
// Flag variables bound via pflag
|
||||
var (
|
||||
compositor string
|
||||
term string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
replaceConfigs []string
|
||||
replaceConfigsAll bool
|
||||
yes bool
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "dankinstall",
|
||||
Short: "Install DankMaterialShell and its dependencies",
|
||||
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
|
||||
|
||||
Without flags, it launches an interactive TUI. Providing either --compositor
|
||||
or --term activates headless (unattended) mode, which requires both flags.
|
||||
|
||||
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
|
||||
configure passwordless sudo for your user.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runDankinstall,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
|
||||
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
|
||||
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
|
||||
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
|
||||
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
|
||||
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
||||
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Getuid() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runDankinstall(cmd *cobra.Command, args []string) error {
|
||||
headlessMode := compositor != "" || term != ""
|
||||
|
||||
if !headlessMode {
|
||||
// Reject headless-only flags when running in TUI mode.
|
||||
headlessOnly := []string{
|
||||
"include-deps",
|
||||
"exclude-deps",
|
||||
"replace-configs",
|
||||
"replace-configs-all",
|
||||
"yes",
|
||||
}
|
||||
var set []string
|
||||
for _, name := range headlessOnly {
|
||||
if cmd.Flags().Changed(name) {
|
||||
set = append(set, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(set) > 0 {
|
||||
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if headlessMode {
|
||||
return runHeadless()
|
||||
}
|
||||
return runTUI()
|
||||
}
|
||||
|
||||
func runHeadless() error {
|
||||
// Validate required flags
|
||||
if compositor == "" {
|
||||
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
|
||||
}
|
||||
if term == "" {
|
||||
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
||||
}
|
||||
|
||||
cfg := headless.Config{
|
||||
Compositor: compositor,
|
||||
Terminal: term,
|
||||
IncludeDeps: includeDeps,
|
||||
ExcludeDeps: excludeDeps,
|
||||
ReplaceConfigs: replaceConfigs,
|
||||
ReplaceConfigsAll: replaceConfigsAll,
|
||||
Yes: yes,
|
||||
}
|
||||
|
||||
runner := headless.NewRunner(cfg)
|
||||
|
||||
// Set up file logging
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
|
||||
fileLogger.StartListening(runner.GetLogChan())
|
||||
defer func() {
|
||||
if err := fileLogger.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// runner log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(runner.GetLogChan())()
|
||||
}
|
||||
|
||||
if err := runner.Run(); err != nil {
|
||||
if fileLogger != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTUI() error {
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
||||
@@ -38,18 +170,50 @@ func main() {
|
||||
|
||||
if fileLogger != nil {
|
||||
fileLogger.StartListening(model.GetLogChan())
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// model log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(model.GetLogChan())()
|
||||
}
|
||||
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("Error running program: %v\n", err)
|
||||
if logFilePath != "" {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error running program: %w", err)
|
||||
}
|
||||
|
||||
if logFilePath != "" {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainLogChan starts a goroutine that discards all messages from logCh,
|
||||
// preventing blocking sends from deadlocking downstream components. It returns
|
||||
// a cleanup function that signals the goroutine to stop and waits for it to
|
||||
// exit. Callers should defer the returned function.
|
||||
func drainLogChan(logCh <-chan string) func() {
|
||||
drainStop := make(chan struct{})
|
||||
drainDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(drainDone)
|
||||
for {
|
||||
select {
|
||||
case <-drainStop:
|
||||
return
|
||||
case _, ok := <-logCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(drainStop)
|
||||
<-drainDone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,10 @@ var authCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var authSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS authentication configuration",
|
||||
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
|
||||
Use: "sync",
|
||||
Short: "Sync DMS authentication configuration",
|
||||
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
|
||||
@@ -236,6 +236,7 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
|
||||
defer ddc.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
|
||||
ddc.WaitPending()
|
||||
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (ds *DoctorStatus) OKCount() int {
|
||||
}
|
||||
|
||||
var (
|
||||
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
||||
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
||||
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
||||
@@ -820,10 +820,14 @@ func checkOptionalDependencies() []checkResult {
|
||||
results = append(results, checkImageFormatPlugins()...)
|
||||
|
||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
|
||||
terminals = slices.DeleteFunc(terminals, func(t string) bool {
|
||||
return !utils.CommandExists(t)
|
||||
})
|
||||
|
||||
if len(terminals) > 0 {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
|
||||
} else {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL})
|
||||
}
|
||||
|
||||
networkResult, err := network.DetectNetworkStack()
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -109,16 +111,37 @@ func updateArchLinux() error {
|
||||
}
|
||||
|
||||
var packageName string
|
||||
if isArchPackageInstalled("dms-shell-bin") {
|
||||
packageName = "dms-shell-bin"
|
||||
var isAUR bool
|
||||
if isArchPackageInstalled("dms-shell") {
|
||||
packageName = "dms-shell"
|
||||
} else if isArchPackageInstalled("dms-shell-git") {
|
||||
packageName = "dms-shell-git"
|
||||
isAUR = true
|
||||
} else if isArchPackageInstalled("dms-shell-bin") {
|
||||
packageName = "dms-shell-bin"
|
||||
isAUR = true
|
||||
} else {
|
||||
fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
|
||||
fmt.Println("Info: No dms-shell package found.")
|
||||
fmt.Println("Info: Falling back to git-based update method...")
|
||||
return updateOtherDistros()
|
||||
}
|
||||
|
||||
if !isAUR {
|
||||
fmt.Printf("This will update %s using pacman.\n", packageName)
|
||||
if !confirmUpdate() {
|
||||
return errdefs.ErrUpdateCancelled
|
||||
}
|
||||
|
||||
fmt.Printf("\nRunning: pacman -S %s\n", packageName)
|
||||
if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil {
|
||||
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("dms successfully updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
var helper string
|
||||
var updateCmd *exec.Cmd
|
||||
|
||||
@@ -454,11 +477,7 @@ func updateDMSBinary() error {
|
||||
|
||||
fmt.Printf("Installing to %s...\n", currentPath)
|
||||
|
||||
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
|
||||
replaceCmd.Stdin = os.Stdin
|
||||
replaceCmd.Stdout = os.Stdout
|
||||
replaceCmd.Stderr = os.Stderr
|
||||
if err := replaceCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/cases"
|
||||
@@ -35,7 +37,7 @@ var greeterInstallCmd = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install and configure DMS greeter",
|
||||
Long: "Install greetd and configure it to use DMS as the greeter interface",
|
||||
PreRunE: requireMutableSystemCommand,
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
@@ -57,9 +59,10 @@ var greeterInstallCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var greeterSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS theme and settings with greeter",
|
||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
|
||||
Use: "sync",
|
||||
Short: "Sync DMS theme and settings with greeter",
|
||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
auth, _ := cmd.Flags().GetBool("auth")
|
||||
@@ -88,7 +91,7 @@ var greeterEnableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable DMS greeter in greetd config",
|
||||
Long: "Configure greetd to use DMS as the greeter",
|
||||
PreRunE: requireMutableSystemCommand,
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
@@ -124,7 +127,7 @@ var greeterUninstallCmd = &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove DMS greeter configuration and restore previous display manager",
|
||||
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
|
||||
PreRunE: requireMutableSystemCommand,
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
@@ -306,10 +309,7 @@ func uninstallGreeter(nonInteractive bool) error {
|
||||
}
|
||||
|
||||
fmt.Println("\nDisabling greetd...")
|
||||
disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd")
|
||||
disableCmd.Stdout = os.Stdout
|
||||
disableCmd.Stderr = os.Stderr
|
||||
if err := disableCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
|
||||
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" ✓ greetd disabled")
|
||||
@@ -375,10 +375,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil {
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
|
||||
@@ -406,21 +406,14 @@ command = "agreety --cmd /bin/bash"
|
||||
}
|
||||
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)
|
||||
}
|
||||
_ = 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)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSudoCommand(_ string, command string, args ...string) error {
|
||||
cmd := exec.Command("sudo", append([]string{command}, args...)...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// suggestDisplayManagerRestore scans for installed DMs and re-enables one
|
||||
func suggestDisplayManagerRestore(nonInteractive bool) {
|
||||
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
||||
@@ -439,10 +432,7 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
|
||||
|
||||
enableDM := func(dm string) {
|
||||
fmt.Printf(" Enabling %s...\n", dm)
|
||||
cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
|
||||
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
|
||||
} else {
|
||||
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
|
||||
@@ -641,10 +631,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
|
||||
if response != "n" && response != "no" {
|
||||
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
|
||||
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
|
||||
addUserCmd.Stdout = os.Stdout
|
||||
addUserCmd.Stderr = os.Stderr
|
||||
if err := addUserCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil {
|
||||
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
|
||||
}
|
||||
fmt.Printf("✓ User added to %s group\n", greeterGroup)
|
||||
@@ -869,22 +856,19 @@ func disableDisplayManager(dmName string) (bool, error) {
|
||||
actionTaken := false
|
||||
|
||||
if state.NeedsDisable {
|
||||
var disableCmd *exec.Cmd
|
||||
var actionVerb string
|
||||
|
||||
if state.EnabledState == "static" {
|
||||
var action, actionVerb string
|
||||
switch state.EnabledState {
|
||||
case "static":
|
||||
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
|
||||
disableCmd = exec.Command("sudo", "systemctl", "mask", dmName)
|
||||
action = "mask"
|
||||
actionVerb = "masked"
|
||||
} else {
|
||||
default:
|
||||
fmt.Printf(" Disabling %s...\n", dmName)
|
||||
disableCmd = exec.Command("sudo", "systemctl", "disable", dmName)
|
||||
action = "disable"
|
||||
actionVerb = "disabled"
|
||||
}
|
||||
|
||||
disableCmd.Stdout = os.Stdout
|
||||
disableCmd.Stderr = os.Stderr
|
||||
if err := disableCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil {
|
||||
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
|
||||
}
|
||||
|
||||
@@ -925,10 +909,7 @@ func ensureGreetdEnabled() error {
|
||||
|
||||
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
|
||||
fmt.Println(" Unmasking greetd...")
|
||||
unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd")
|
||||
unmaskCmd.Stdout = os.Stdout
|
||||
unmaskCmd.Stderr = os.Stderr
|
||||
if err := unmaskCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil {
|
||||
return fmt.Errorf("failed to unmask greetd: %w", err)
|
||||
}
|
||||
fmt.Println(" ✓ Unmasked greetd")
|
||||
@@ -940,10 +921,7 @@ func ensureGreetdEnabled() error {
|
||||
fmt.Println(" Enabling greetd service...")
|
||||
}
|
||||
|
||||
enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd")
|
||||
enableCmd.Stdout = os.Stdout
|
||||
enableCmd.Stderr = os.Stderr
|
||||
if err := enableCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil {
|
||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||
}
|
||||
|
||||
@@ -973,10 +951,7 @@ func ensureGraphicalTarget() error {
|
||||
currentTargetStr := strings.TrimSpace(string(currentTarget))
|
||||
if currentTargetStr != "graphical.target" {
|
||||
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
|
||||
setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target")
|
||||
setDefaultCmd.Stdout = os.Stdout
|
||||
setDefaultCmd.Stderr = os.Stderr
|
||||
if err := setDefaultCmd.Run(); err != nil {
|
||||
if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil {
|
||||
fmt.Println("⚠ Warning: Failed to set graphical.target as default")
|
||||
fmt.Println(" Greeter may not start on boot. Run manually:")
|
||||
fmt.Println(" sudo systemctl set-default graphical.target")
|
||||
|
||||
@@ -19,7 +19,7 @@ var setupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Deploy DMS configurations",
|
||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||
PersistentPreRunE: requireMutableSystemCommand,
|
||||
PersistentPreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetup(); err != nil {
|
||||
log.Fatalf("Error during setup: %v", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,9 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Geteuid() == 0 {
|
||||
clipboard.MaybeServeAndExit()
|
||||
|
||||
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,9 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Geteuid() == 0 {
|
||||
clipboard.MaybeServeAndExit()
|
||||
|
||||
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,22 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isReadOnlyCommand returns true if the CLI args indicate a command that is
|
||||
// safe to run as root (e.g. shell completion, help).
|
||||
func isReadOnlyCommand(args []string) bool {
|
||||
for _, arg := range args[1:] {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "completion", "help", "__complete":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isArchPackageInstalled(packageName string) bool {
|
||||
cmd := exec.Command("pacman", "-Q", packageName)
|
||||
err := cmd.Run()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,66 +12,142 @@ import (
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||
const envMime = "_DMS_CLIPBOARD_MIME"
|
||||
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||
|
||||
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||
func MaybeServeAndExit() {
|
||||
if os.Getenv(envServe) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := os.Getenv(envMime)
|
||||
pasteOnce := os.Getenv(envPasteOnce) == "1"
|
||||
cachePath := os.Getenv(envCacheFile)
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case cachePath != "":
|
||||
data, err = os.ReadFile(cachePath)
|
||||
os.Remove(cachePath)
|
||||
default:
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func Copy(data []byte, mimeType string) error {
|
||||
return CopyReader(bytes.NewReader(data), mimeType, false, false)
|
||||
return copyForkCached(data, mimeType, false)
|
||||
}
|
||||
|
||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return copyServeWithWriter(func(writer io.Writer) error {
|
||||
total := 0
|
||||
for total < len(data) {
|
||||
n, err := writer.Write(data[total:])
|
||||
total += n
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if total != len(data) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}, mimeType, pasteOnce)
|
||||
return serveClipboard(data, mimeType, pasteOnce)
|
||||
}
|
||||
return CopyReader(bytes.NewReader(data), mimeType, foreground, pasteOnce)
|
||||
return copyForkCached(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||
if !foreground {
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
if foreground {
|
||||
buf, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveClipboard(buf, mimeType, pasteOnce)
|
||||
}
|
||||
return copyServeReader(data, mimeType, pasteOnce)
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
args = append(args, "--type", mimeType)
|
||||
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||
cmd := exec.Command(os.Args[0])
|
||||
cmd.Stderr = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
cmd.Env = append(os.Environ(), "DMS_CLIP_FORKED=1")
|
||||
cmd.Env = append(os.Environ(),
|
||||
envServe+"=1",
|
||||
envMime+"="+mimeType,
|
||||
)
|
||||
if pasteOnce {
|
||||
cmd.Env = append(cmd.Env, envPasteOnce+"=1")
|
||||
}
|
||||
cmd.Env = append(cmd.Env, extra...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func waitReady(cmd *exec.Cmd) error {
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
||||
cacheFile, err := createClipboardCacheFile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create cache file: %w", err)
|
||||
}
|
||||
cachePath := cacheFile.Name()
|
||||
|
||||
if _, err := cacheFile.Write(data); err != nil {
|
||||
cacheFile.Close()
|
||||
os.Remove(cachePath)
|
||||
return fmt.Errorf("write cache file: %w", err)
|
||||
}
|
||||
if err := cacheFile.Close(); err != nil {
|
||||
os.Remove(cachePath)
|
||||
return fmt.Errorf("close cache file: %w", err)
|
||||
}
|
||||
|
||||
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
|
||||
cmd.Stdin = nil
|
||||
if err := waitReady(cmd); err != nil {
|
||||
os.Remove(cachePath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
cmd := newForkCmd(mimeType, pasteOnce)
|
||||
|
||||
switch src := data.(type) {
|
||||
case *os.File:
|
||||
cmd.Stdin = src
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
return waitReady(cmd)
|
||||
|
||||
default:
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
@@ -83,50 +158,22 @@ func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signalReady() {
|
||||
if os.Getenv("DMS_CLIP_FORKED") == "" {
|
||||
if os.Getenv(envServe) == "" {
|
||||
return
|
||||
}
|
||||
os.Stdout.Write([]byte{1})
|
||||
}
|
||||
|
||||
func copyServeReader(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
cachedData, err := createClipboardCacheFile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create clipboard cache file: %w", err)
|
||||
}
|
||||
defer os.Remove(cachedData.Name())
|
||||
|
||||
if _, err := io.Copy(cachedData, data); err != nil {
|
||||
return fmt.Errorf("cache clipboard data: %w", err)
|
||||
}
|
||||
if err := cachedData.Close(); err != nil {
|
||||
return fmt.Errorf("close temp cache file: %w", err)
|
||||
}
|
||||
|
||||
return copyServeWithWriter(func(writer io.Writer) error {
|
||||
cachedFile, err := os.Open(cachedData.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("open temp cache file: %w", err)
|
||||
}
|
||||
defer cachedFile.Close()
|
||||
|
||||
if _, err := io.Copy(writer, cachedFile); err != nil {
|
||||
return fmt.Errorf("write clipboard data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func createClipboardCacheFile() (*os.File, error) {
|
||||
preferredDirs := []string{}
|
||||
|
||||
@@ -147,7 +194,7 @@ func createClipboardCacheFile() (*os.File, error) {
|
||||
return os.CreateTemp("", "dms-clipboard-*")
|
||||
}
|
||||
|
||||
func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOnce bool) error {
|
||||
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
@@ -189,12 +236,10 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
@@ -233,18 +278,12 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
pasted := make(chan struct{}, 1)
|
||||
sendErr := make(chan error, 1)
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
defer syscall.Close(e.Fd)
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
if err := writeTo(file); err != nil {
|
||||
select {
|
||||
case sendErr <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
_, _ = file.Write(data)
|
||||
select {
|
||||
case pasted <- struct{}{}:
|
||||
default:
|
||||
@@ -266,8 +305,6 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
|
||||
select {
|
||||
case <-cancelled:
|
||||
return nil
|
||||
case err := <-sendErr:
|
||||
return err
|
||||
case <-pasted:
|
||||
if pasteOnce {
|
||||
return nil
|
||||
@@ -521,12 +558,10 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
@@ -554,12 +589,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
|
||||
pasted := make(chan struct{}, 1)
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
defer syscall.Close(e.Fd)
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
|
||||
if data, ok := offerMap[e.MimeType]; ok {
|
||||
file.Write(data)
|
||||
_, _ = file.Write(data)
|
||||
}
|
||||
|
||||
select {
|
||||
|
||||
@@ -39,11 +39,10 @@ type LayerSurface struct {
|
||||
wlSurface *client.Surface
|
||||
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
|
||||
viewport *wp_viewporter.WpViewport
|
||||
wlPool *client.ShmPool
|
||||
wlBuffer *client.Buffer
|
||||
bufferBusy bool
|
||||
oldPool *client.ShmPool
|
||||
oldBuffer *client.Buffer
|
||||
wlPools [2]*client.ShmPool
|
||||
wlBuffers [2]*client.Buffer
|
||||
slotBusy [2]bool
|
||||
needsRedraw bool
|
||||
scopyBuffer *client.Buffer
|
||||
configured bool
|
||||
hidden bool
|
||||
@@ -136,6 +135,7 @@ func (p *Picker) Run() (*Color, error) {
|
||||
break
|
||||
}
|
||||
|
||||
p.flushRedraws()
|
||||
p.checkDone()
|
||||
}
|
||||
|
||||
@@ -164,6 +164,15 @@ func (p *Picker) checkDone() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Picker) flushRedraws() {
|
||||
for _, ls := range p.surfaces {
|
||||
if !ls.needsRedraw {
|
||||
continue
|
||||
}
|
||||
p.redrawSurface(ls)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Picker) connect() error {
|
||||
display, err := client.Connect("")
|
||||
if err != nil {
|
||||
@@ -507,47 +516,45 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
|
||||
}
|
||||
|
||||
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
||||
slot := ls.state.FrontIndex()
|
||||
if ls.slotBusy[slot] {
|
||||
ls.needsRedraw = true
|
||||
return
|
||||
}
|
||||
|
||||
var renderBuf *ShmBuffer
|
||||
if ls.hidden {
|
||||
switch {
|
||||
case ls.hidden:
|
||||
renderBuf = ls.state.RedrawScreenOnly()
|
||||
} else {
|
||||
default:
|
||||
renderBuf = ls.state.Redraw()
|
||||
}
|
||||
if renderBuf == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if ls.oldBuffer != nil {
|
||||
ls.oldBuffer.Destroy()
|
||||
ls.oldBuffer = nil
|
||||
}
|
||||
if ls.oldPool != nil {
|
||||
ls.oldPool.Destroy()
|
||||
ls.oldPool = nil
|
||||
ls.needsRedraw = false
|
||||
|
||||
if ls.wlPools[slot] == nil {
|
||||
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ls.wlPools[slot] = pool
|
||||
|
||||
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ls.wlBuffers[slot] = wlBuffer
|
||||
|
||||
s := slot
|
||||
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
|
||||
ls.slotBusy[s] = false
|
||||
})
|
||||
}
|
||||
|
||||
ls.oldPool = ls.wlPool
|
||||
ls.oldBuffer = ls.wlBuffer
|
||||
ls.wlPool = nil
|
||||
ls.wlBuffer = nil
|
||||
|
||||
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ls.wlPool = pool
|
||||
|
||||
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ls.wlBuffer = wlBuffer
|
||||
|
||||
lsRef := ls
|
||||
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
|
||||
lsRef.bufferBusy = false
|
||||
})
|
||||
ls.bufferBusy = true
|
||||
ls.slotBusy[slot] = true
|
||||
|
||||
logicalW, logicalH := ls.state.LogicalSize()
|
||||
if logicalW == 0 || logicalH == 0 {
|
||||
@@ -566,7 +573,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
|
||||
}
|
||||
_ = ls.wlSurface.SetBufferScale(bufferScale)
|
||||
}
|
||||
_ = ls.wlSurface.Attach(wlBuffer, 0, 0)
|
||||
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
|
||||
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
|
||||
_ = ls.wlSurface.Commit()
|
||||
|
||||
@@ -634,7 +641,7 @@ func (p *Picker) setupPointerHandlers() {
|
||||
}
|
||||
|
||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||
p.redrawSurface(p.activeSurface)
|
||||
p.activeSurface.needsRedraw = true
|
||||
})
|
||||
|
||||
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
|
||||
@@ -655,7 +662,7 @@ func (p *Picker) setupPointerHandlers() {
|
||||
return
|
||||
}
|
||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||
p.redrawSurface(p.activeSurface)
|
||||
p.activeSurface.needsRedraw = true
|
||||
})
|
||||
|
||||
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
|
||||
@@ -679,17 +686,13 @@ func (p *Picker) cleanup() {
|
||||
if ls.scopyBuffer != nil {
|
||||
ls.scopyBuffer.Destroy()
|
||||
}
|
||||
if ls.oldBuffer != nil {
|
||||
ls.oldBuffer.Destroy()
|
||||
}
|
||||
if ls.oldPool != nil {
|
||||
ls.oldPool.Destroy()
|
||||
}
|
||||
if ls.wlBuffer != nil {
|
||||
ls.wlBuffer.Destroy()
|
||||
}
|
||||
if ls.wlPool != nil {
|
||||
ls.wlPool.Destroy()
|
||||
for i := range ls.wlBuffers {
|
||||
if ls.wlBuffers[i] != nil {
|
||||
ls.wlBuffers[i].Destroy()
|
||||
}
|
||||
if ls.wlPools[i] != nil {
|
||||
ls.wlPools[i].Destroy()
|
||||
}
|
||||
}
|
||||
if ls.viewport != nil {
|
||||
ls.viewport.Destroy()
|
||||
|
||||
@@ -274,6 +274,12 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
|
||||
return s.renderBufs[s.front]
|
||||
}
|
||||
|
||||
func (s *SurfaceState) FrontIndex() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.front
|
||||
}
|
||||
|
||||
func (s *SurfaceState) SwapBuffers() {
|
||||
s.mu.Lock()
|
||||
s.front ^= 1
|
||||
|
||||
@@ -62,12 +62,31 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
|
||||
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||
var results []DeploymentResult
|
||||
|
||||
// Primary config file paths used to detect fresh installs.
|
||||
configPrimaryPaths := map[string]string{
|
||||
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
||||
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
||||
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
|
||||
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
|
||||
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
|
||||
}
|
||||
|
||||
shouldReplaceConfig := func(configType string) bool {
|
||||
if replaceConfigs == nil {
|
||||
return true
|
||||
}
|
||||
replace, exists := replaceConfigs[configType]
|
||||
return !exists || replace
|
||||
if !exists || replace {
|
||||
return true
|
||||
}
|
||||
// Config is explicitly set to "don't replace" — but still deploy
|
||||
// if the config file doesn't exist yet (fresh install scenario).
|
||||
if primaryPath, ok := configPrimaryPaths[configType]; ok {
|
||||
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -624,3 +625,168 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
|
||||
assert.Contains(t, string(newContent), "decorations = \"None\"")
|
||||
})
|
||||
}
|
||||
|
||||
func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
||||
allFalse := map[string]bool{
|
||||
"Niri": false,
|
||||
"Hyprland": false,
|
||||
"Ghostty": false,
|
||||
"Kitty": false,
|
||||
"Alacritty": false,
|
||||
}
|
||||
|
||||
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
nil, // replaceConfigs
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// With replaceConfigs=nil, all configs should be deployed
|
||||
hasDeployed := false
|
||||
for _, r := range results {
|
||||
if r.Deployed {
|
||||
hasDeployed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil")
|
||||
})
|
||||
|
||||
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
allFalse, // replaceConfigs — all false
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config files don't exist on disk, so they should still be deployed
|
||||
hasDeployed := false
|
||||
for _, r := range results {
|
||||
if r.Deployed {
|
||||
hasDeployed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false")
|
||||
})
|
||||
|
||||
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
// Create the Ghostty primary config file so shouldReplaceConfig returns false
|
||||
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Also create the Niri primary config file
|
||||
niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl")
|
||||
err = os.MkdirAll(filepath.Dir(niriPath), 0o755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
allFalse, // replaceConfigs — all false
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both Niri and Ghostty config files exist, so with all false they should be skipped
|
||||
for _, r := range results {
|
||||
assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
// Create the Ghostty primary config file
|
||||
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
replaceConfigs := map[string]bool{
|
||||
"Niri": false,
|
||||
"Hyprland": false,
|
||||
"Ghostty": true, // explicitly true
|
||||
"Kitty": false,
|
||||
"Alacritty": false,
|
||||
}
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
replaceConfigs, // Ghostty=true, rest=false
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ghostty should be deployed because replaceConfigs["Ghostty"]=true
|
||||
foundGhostty := false
|
||||
for _, r := range results {
|
||||
if r.ConfigType == "Ghostty" && r.Deployed {
|
||||
foundGhostty = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ bind = SUPER, bracketright, layoutmsg, preselect r
|
||||
|
||||
# === Sizing & Layout ===
|
||||
bind = SUPER, R, layoutmsg, togglesplit
|
||||
bind = SUPER CTRL, F, resizeactive, exact 100%
|
||||
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
|
||||
|
||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||
bindmd = SUPER, mouse:272, Move window, movewindow
|
||||
|
||||
@@ -94,6 +94,7 @@ windowrule = tile on, match:class ^(gnome-control-center)$
|
||||
windowrule = tile on, match:class ^(pavucontrol)$
|
||||
windowrule = tile on, match:class ^(nm-connection-editor)$
|
||||
|
||||
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
|
||||
windowrule = float on, match:class ^(gnome-calculator)$
|
||||
windowrule = float on, match:class ^(galculator)$
|
||||
windowrule = float on, match:class ^(blueman-manager)$
|
||||
|
||||
@@ -224,6 +224,7 @@ window-rule {
|
||||
open-floating false
|
||||
}
|
||||
window-rule {
|
||||
match app-id=r#"^org\.gnome\.Calculator$"#
|
||||
match app-id=r#"^gnome-calculator$"#
|
||||
match app-id=r#"^galculator$"#
|
||||
match app-id=r#"^blueman-manager$"#
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -242,11 +243,7 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
|
||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
if a.packageInstalled("dms-shell-bin") {
|
||||
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
|
||||
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||
@@ -296,7 +293,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
|
||||
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 {
|
||||
return fmt.Errorf("failed to install base-devel: %w", err)
|
||||
}
|
||||
@@ -328,6 +325,13 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
||||
|
||||
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||
|
||||
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
|
||||
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
|
||||
}
|
||||
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
||||
}
|
||||
|
||||
// Phase 3: System Packages
|
||||
if len(systemPkgs) > 0 {
|
||||
progressChan <- InstallProgressMsg{
|
||||
@@ -445,6 +449,37 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
|
||||
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if a.packageInstalled("quickshell-git") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if a.packageInstalled("quickshell") {
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
Progress: 0.15,
|
||||
Step: "Removing stable quickshell...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
|
||||
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
|
||||
}
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
|
||||
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
|
||||
return fmt.Errorf("failed to remove stable quickshell: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
Progress: 0.18,
|
||||
Step: "Building quickshell-git before system packages...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
|
||||
}
|
||||
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if len(packages) == 0 {
|
||||
return nil
|
||||
@@ -453,6 +488,9 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
||||
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
||||
|
||||
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
|
||||
if slices.Contains(packages, "dms-shell") {
|
||||
args = append(args, "--assume-installed", "dms-shell-compositor=1")
|
||||
}
|
||||
args = append(args, packages...)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
@@ -464,7 +502,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -540,7 +578,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
||||
var dmsShell []string
|
||||
|
||||
for _, pkg := range packages {
|
||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
||||
if pkg == "dms-shell-git" {
|
||||
dmsShell = append(dmsShell, pkg)
|
||||
} else {
|
||||
isDep := false
|
||||
@@ -621,7 +659,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
||||
if pkg == "dms-shell-git" {
|
||||
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
||||
depsToRemove := []string{
|
||||
"depends = quickshell",
|
||||
@@ -644,15 +682,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
||||
}
|
||||
|
||||
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
|
||||
if pkg == "dms-shell-bin" {
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
Progress: startProgress + 0.35*(endProgress-startProgress),
|
||||
Step: fmt.Sprintf("Skipping dependency installation for %s (manually managed)...", pkg),
|
||||
IsComplete: false,
|
||||
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
|
||||
}
|
||||
} else {
|
||||
{
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
Progress: startProgress + 0.3*(endProgress-startProgress),
|
||||
@@ -739,42 +769,9 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
||||
CommandInfo: "sudo pacman -U built-package",
|
||||
}
|
||||
|
||||
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
|
||||
var files []string
|
||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
||||
// For DMS split packages, install base package
|
||||
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err == nil {
|
||||
for _, match := range matches {
|
||||
basename := filepath.Base(match)
|
||||
// Always include base package
|
||||
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
|
||||
files = append(files, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also update compositor-specific packages if they're installed
|
||||
if strings.HasSuffix(pkg, "-git") {
|
||||
if a.packageInstalled("dms-shell-hyprland-git") {
|
||||
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
|
||||
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
|
||||
files = append(files, hyprlandMatches[0])
|
||||
}
|
||||
}
|
||||
if a.packageInstalled("dms-shell-niri-git") {
|
||||
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
|
||||
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
|
||||
files = append(files, niriMatches[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other packages, install all built packages
|
||||
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
|
||||
files = matches
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
|
||||
files = matches
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no package files found after building %s", pkg)
|
||||
@@ -783,7 +780,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
||||
installArgs := []string{"pacman", "-U", "--noconfirm"}
|
||||
installArgs = append(installArgs, files...)
|
||||
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
|
||||
|
||||
fileNames := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||
)
|
||||
|
||||
@@ -55,27 +56,6 @@ func (b *BaseDistribution) logError(message string, err error) {
|
||||
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 {
|
||||
status := deps.StatusMissing
|
||||
if b.commandExists(name) {
|
||||
@@ -710,7 +690,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
|
||||
}
|
||||
|
||||
// Install to /usr/local/bin
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
|
||||
if err := installCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install DMS binary: %w", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -182,7 +183,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
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 {
|
||||
return fmt.Errorf("failed to update package lists: %w", err)
|
||||
}
|
||||
@@ -199,7 +200,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
|
||||
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
|
||||
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 {
|
||||
return fmt.Errorf("failed to install build-essential: %w", err)
|
||||
}
|
||||
@@ -215,7 +216,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
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")
|
||||
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
|
||||
return fmt.Errorf("failed to install development tools: %w", err)
|
||||
@@ -441,7 +442,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
||||
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
|
||||
|
||||
// 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 {
|
||||
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
|
||||
}
|
||||
@@ -455,7 +456,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)
|
||||
cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd)
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
|
||||
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)
|
||||
}
|
||||
@@ -471,7 +472,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
||||
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))
|
||||
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)
|
||||
@@ -491,7 +492,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
||||
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 {
|
||||
return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
|
||||
}
|
||||
@@ -537,7 +538,7 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -625,7 +626,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
args := []string{"apt-get", "install", "-y"}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -643,7 +644,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
|
||||
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 {
|
||||
return fmt.Errorf("failed to install rustup: %w", err)
|
||||
}
|
||||
@@ -682,7 +683,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -254,7 +255,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
|
||||
args := []string{"dnf", "install", "-y"}
|
||||
args = append(args, missingPkgs...)
|
||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
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),
|
||||
}
|
||||
|
||||
cmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
|
||||
output, err := cmd.CombinedOutput()
|
||||
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),
|
||||
}
|
||||
|
||||
priorityCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
priorityCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
|
||||
priorityOutput, err := priorityCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -537,7 +538,7 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
var GentooGlobalUseFlags = []string{
|
||||
@@ -201,9 +202,9 @@ func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword
|
||||
|
||||
var cmd *exec.Cmd
|
||||
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 {
|
||||
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()
|
||||
@@ -281,7 +282,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
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()
|
||||
if syncErr != nil {
|
||||
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 = append(args, missingPkgs...)
|
||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
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, " ")),
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
|
||||
packageUseDir := "/etc/portage/package.use"
|
||||
|
||||
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("mkdir -p %s", packageUseDir))
|
||||
if output, err := mkdirCmd.CombinedOutput(); err != nil {
|
||||
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 {
|
||||
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", 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))
|
||||
if output, err := replaceCmd.CombinedOutput(); err != nil {
|
||||
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))
|
||||
|
||||
output, err := appendCmd.CombinedOutput()
|
||||
@@ -557,7 +558,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
|
||||
}
|
||||
|
||||
// Enable GURU repository
|
||||
enableCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
enableCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
|
||||
output, err := enableCmd.CombinedOutput()
|
||||
|
||||
@@ -589,7 +590,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
|
||||
LogOutput: "Syncing GURU repository",
|
||||
}
|
||||
|
||||
syncCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
syncCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
|
||||
syncOutput, syncErr := syncCmd.CombinedOutput()
|
||||
|
||||
@@ -622,7 +623,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
|
||||
|
||||
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
|
||||
|
||||
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
|
||||
if output, err := mkdirCmd.CombinedOutput(); err != nil {
|
||||
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 {
|
||||
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", 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))
|
||||
if output, err := replaceCmd.CombinedOutput(); err != nil {
|
||||
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))
|
||||
|
||||
output, err := appendCmd.CombinedOutput()
|
||||
@@ -695,6 +696,6 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
// 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",
|
||||
}
|
||||
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
|
||||
installCmd.Dir = tmpDir
|
||||
if err := installCmd.Run(); err != nil {
|
||||
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",
|
||||
}
|
||||
|
||||
installDebCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
installDebCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
|
||||
|
||||
output, err := installDebCmd.CombinedOutput()
|
||||
@@ -324,7 +325,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
|
||||
CommandInfo: "sudo cmake --install build",
|
||||
}
|
||||
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
|
||||
installCmd.Dir = tmpDir
|
||||
if err := installCmd.Run(); err != nil {
|
||||
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",
|
||||
}
|
||||
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
|
||||
installCmd.Dir = tmpDir
|
||||
if err := installCmd.Run(); err != nil {
|
||||
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/",
|
||||
}
|
||||
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
|
||||
if err := installCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install Ghostty: %w", err)
|
||||
@@ -492,16 +493,11 @@ func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPasswor
|
||||
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
|
||||
}
|
||||
|
||||
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
|
||||
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := copyCmd.Run(); err != nil {
|
||||
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
|
||||
}
|
||||
|
||||
// Make it executable
|
||||
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
|
||||
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := chmodCmd.Run(); err != nil {
|
||||
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
|
||||
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),
|
||||
}
|
||||
|
||||
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
|
||||
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := copyCmd.Run(); err != nil {
|
||||
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
|
||||
}
|
||||
|
||||
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
|
||||
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := chmodCmd.Run(); err != nil {
|
||||
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
|
||||
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -250,7 +251,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas
|
||||
|
||||
args := []string{"zypper", "install", "-y"}
|
||||
args = append(args, missingPkgs...)
|
||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
o.logError("failed to install prerequisites", err)
|
||||
@@ -486,7 +487,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
|
||||
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
|
||||
}
|
||||
|
||||
cmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("zypper addrepo -f %s", repoURL))
|
||||
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))
|
||||
@@ -507,7 +508,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
|
||||
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 {
|
||||
return fmt.Errorf("failed to refresh repositories: %w", err)
|
||||
}
|
||||
@@ -588,7 +589,7 @@ func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sud
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
|
||||
@@ -646,7 +647,7 @@ func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packag
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -774,7 +775,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
|
||||
CommandInfo: "sudo cmake --install build",
|
||||
}
|
||||
|
||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
|
||||
installCmd.Dir = tmpDir
|
||||
if err := installCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install quickshell: %w", err)
|
||||
@@ -798,7 +799,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str
|
||||
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 {
|
||||
return fmt.Errorf("failed to install rustup: %w", err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -177,7 +178,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
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 {
|
||||
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")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
// 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 {
|
||||
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",
|
||||
}
|
||||
|
||||
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")
|
||||
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
|
||||
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 {
|
||||
enabledRepos := make(map[string]bool)
|
||||
|
||||
installPPACmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
installPPACmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
"apt-get install -y software-properties-common")
|
||||
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
|
||||
return fmt.Errorf("failed to install software-properties-common: %w", err)
|
||||
@@ -416,7 +417,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
|
||||
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))
|
||||
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)
|
||||
@@ -437,7 +438,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
|
||||
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 {
|
||||
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, " ")),
|
||||
}
|
||||
|
||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
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 = 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)
|
||||
}
|
||||
|
||||
@@ -609,7 +610,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
|
||||
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 {
|
||||
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",
|
||||
}
|
||||
|
||||
addPPACmd := ExecSudoCommand(ctx, sudoPassword,
|
||||
addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
"add-apt-repository -y ppa:longsleep/golang-backports")
|
||||
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
|
||||
return fmt.Errorf("failed to add Go PPA: %w", err)
|
||||
@@ -664,7 +665,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
|
||||
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 {
|
||||
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",
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/sblinch/kdl-go"
|
||||
"github.com/sblinch/kdl-go/document"
|
||||
@@ -327,56 +328,17 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
|
||||
|
||||
switch config.Family {
|
||||
case distros.FamilyArch:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
||||
"pacman -S --needed --noconfirm greetd")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm greetd")
|
||||
case distros.FamilyFedora:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
||||
"dnf install -y greetd")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y greetd")
|
||||
case distros.FamilySUSE:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
||||
"zypper install -y greetd")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd")
|
||||
}
|
||||
|
||||
case distros.FamilyUbuntu:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
||||
"apt-get install -y greetd")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
|
||||
}
|
||||
|
||||
case distros.FamilyDebian:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
||||
"apt-get install -y greetd")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y greetd")
|
||||
case distros.FamilyUbuntu, distros.FamilyDebian:
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
|
||||
case distros.FamilyGentoo:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
||||
"emerge --ask n sys-apps/greetd")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
|
||||
case distros.FamilyNix:
|
||||
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
|
||||
}
|
||||
@@ -437,56 +399,56 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
|
||||
logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug))
|
||||
if _, err := exec.LookPath("gpg"); err != nil {
|
||||
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.Stderr = os.Stderr
|
||||
if err := installGPGCmd.Run(); err != nil {
|
||||
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.Stderr = os.Stderr
|
||||
mkdirCmd.Run()
|
||||
addKeyCmd := exec.CommandContext(ctx, "bash", "-c",
|
||||
fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL))
|
||||
addKeyCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf(`bash -c "curl -fsSL %s | gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg"`, keyURL))
|
||||
addKeyCmd.Stdout = os.Stdout
|
||||
addKeyCmd.Stderr = os.Stderr
|
||||
addKeyCmd.Run()
|
||||
addRepoCmd := exec.CommandContext(ctx, "bash", "-c",
|
||||
fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine))
|
||||
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf(`bash -c "echo '%s' > /etc/apt/sources.list.d/danklinux.list"`, repoLine))
|
||||
addRepoCmd.Stdout = os.Stdout
|
||||
addRepoCmd.Stderr = os.Stderr
|
||||
addRepoCmd.Run()
|
||||
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run()
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter")
|
||||
privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
|
||||
case distros.FamilySUSE:
|
||||
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)
|
||||
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.Stderr = os.Stderr
|
||||
addRepoCmd.Run()
|
||||
exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run()
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter")
|
||||
privesc.ExecCommand(ctx, sudoPassword, "zypper refresh").Run()
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y dms-greeter")
|
||||
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"
|
||||
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.Stderr = os.Stderr
|
||||
ppacmd.Run()
|
||||
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run()
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter")
|
||||
privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
|
||||
case distros.FamilyFedora:
|
||||
failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter"
|
||||
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.Stderr = os.Stderr
|
||||
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:
|
||||
aurHelper := ""
|
||||
for _, helper := range []string{"paru", "yay"} {
|
||||
@@ -539,25 +501,25 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
|
||||
if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err == nil {
|
||||
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) {
|
||||
if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
|
||||
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))
|
||||
} else {
|
||||
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))
|
||||
} else {
|
||||
logFunc("✓ Restored SELinux context for dms-greeter")
|
||||
@@ -583,7 +545,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
||||
if !os.IsNotExist(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)
|
||||
}
|
||||
created = true
|
||||
@@ -595,17 +557,17 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
||||
daemonUser := DetectGreeterUser()
|
||||
preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group)
|
||||
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
|
||||
// to root:<group> while still allowing group-writable greeter runtime access.
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -616,13 +578,13 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
||||
filepath.Join(cacheDir, ".cache"),
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -634,7 +596,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -659,13 +621,13 @@ func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPat
|
||||
info, err := os.Lstat(legacyPath)
|
||||
if err == nil && info.Mode().IsRegular() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -692,7 +654,7 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -709,15 +671,15 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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(" Run: sudo apparmor_parser -r " + appArmorProfileDest)
|
||||
} else {
|
||||
@@ -745,9 +707,9 @@ func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
logFunc(" ✓ Removed DMS AppArmor profile")
|
||||
@@ -777,50 +739,17 @@ func EnsureACLInstalled(logFunc func(string), sudoPassword string) error {
|
||||
|
||||
switch config.Family {
|
||||
case distros.FamilyArch:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
|
||||
case distros.FamilyFedora:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y acl")
|
||||
case distros.FamilySUSE:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl")
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y acl")
|
||||
case distros.FamilyUbuntu, distros.FamilyDebian:
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y acl")
|
||||
case distros.FamilyGentoo:
|
||||
if sudoPassword != "" {
|
||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
|
||||
} else {
|
||||
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl")
|
||||
}
|
||||
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
|
||||
case distros.FamilyNix:
|
||||
return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix")
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family)
|
||||
}
|
||||
@@ -877,7 +806,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
|
||||
}
|
||||
|
||||
// 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(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path))
|
||||
continue
|
||||
@@ -934,7 +863,7 @@ func RemediateStaleACLs(logFunc func(string), sudoPassword string) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -974,7 +903,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).
|
||||
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)
|
||||
}
|
||||
logFunc(fmt.Sprintf("✓ Created system group %s", group))
|
||||
@@ -985,7 +914,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
if err == nil && strings.Contains(string(groupsOutput), group) {
|
||||
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
|
||||
} 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)
|
||||
}
|
||||
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
|
||||
@@ -1000,7 +929,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
||||
if strings.Contains(string(daemonGroupsOutput), group) {
|
||||
logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group))
|
||||
} 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))
|
||||
} else {
|
||||
logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group))
|
||||
@@ -1030,12 +959,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))
|
||||
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))
|
||||
continue
|
||||
}
|
||||
@@ -1247,8 +1176,8 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
|
||||
}
|
||||
|
||||
target := filepath.Join(cacheDir, "colors.json")
|
||||
_ = runSudoCmd(sudoPassword, "rm", "-f", target)
|
||||
if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil {
|
||||
_ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", target)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1300,9 +1229,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)
|
||||
}
|
||||
|
||||
@@ -1340,13 +1269,13 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
|
||||
func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error {
|
||||
destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
|
||||
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)
|
||||
}
|
||||
logFunc("✓ Cleared greeter wallpaper override")
|
||||
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)
|
||||
}
|
||||
src := state.ResolvedGreeterWallpaperPath
|
||||
@@ -1357,17 +1286,17 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
|
||||
if st.IsDir() {
|
||||
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)
|
||||
}
|
||||
greeterGroup := DetectGreeterGroup()
|
||||
daemonUser := DetectGreeterUser()
|
||||
if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil {
|
||||
if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil {
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != 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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
logFunc("✓ Synced greeter wallpaper override")
|
||||
@@ -1422,13 +1351,13 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
||||
|
||||
greeterDir := "/etc/greetd/niri"
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1450,7 +1379,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
||||
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1473,7 +1402,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
||||
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1549,7 +1478,7 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1565,10 +1494,10 @@ func backupFileIfExists(sudoPassword string, path string, suffix string) error {
|
||||
}
|
||||
|
||||
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 runSudoCmd(sudoPassword, "chmod", "644", backupPath)
|
||||
return privesc.Run(context.Background(), sudoPassword, "chmod", "644", backupPath)
|
||||
}
|
||||
|
||||
func (s *niriGreeterSync) processFile(filePath string) error {
|
||||
@@ -1804,11 +1733,11 @@ vt = 1
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1912,27 +1841,6 @@ func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string {
|
||||
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) {
|
||||
cmd := exec.Command("systemctl", "is-enabled", service)
|
||||
output, _ := cmd.Output()
|
||||
@@ -1949,7 +1857,7 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
|
||||
switch state {
|
||||
case "enabled", "enabled-runtime", "static", "indirect", "alias":
|
||||
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))
|
||||
} else {
|
||||
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
|
||||
@@ -1970,13 +1878,13 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
||||
}
|
||||
if state == "masked" || state == "masked-runtime" {
|
||||
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)
|
||||
}
|
||||
logFunc(" ✓ Unmasked greetd")
|
||||
}
|
||||
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)
|
||||
}
|
||||
logFunc("✓ greetd enabled")
|
||||
@@ -1996,7 +1904,7 @@ func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
logFunc("✓ Default target set to graphical.target")
|
||||
|
||||
439
core/internal/headless/runner.go
Normal file
439
core/internal/headless/runner.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package headless
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
// ErrConfirmationRequired is returned when --yes is not set and the user
|
||||
// must explicitly confirm the operation.
|
||||
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
|
||||
|
||||
// validConfigNames maps lowercase CLI input to the deployer key used in
|
||||
// replaceConfigs. Keep in sync with the config types checked by
|
||||
// shouldReplaceConfig in deployer.go.
|
||||
var validConfigNames = map[string]string{
|
||||
"niri": "Niri",
|
||||
"hyprland": "Hyprland",
|
||||
"ghostty": "Ghostty",
|
||||
"kitty": "Kitty",
|
||||
"alacritty": "Alacritty",
|
||||
}
|
||||
|
||||
// orderedConfigNames defines the canonical order for config names in output.
|
||||
// Must be kept in sync with validConfigNames.
|
||||
var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
|
||||
|
||||
// Config holds all CLI parameters for unattended installation.
|
||||
type Config struct {
|
||||
Compositor string // "niri" or "hyprland"
|
||||
Terminal string // "ghostty", "kitty", or "alacritty"
|
||||
IncludeDeps []string
|
||||
ExcludeDeps []string
|
||||
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
||||
ReplaceConfigsAll bool // deploy/replace all configurations
|
||||
Yes bool
|
||||
}
|
||||
|
||||
// Runner orchestrates unattended (headless) installation.
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
logChan chan string
|
||||
}
|
||||
|
||||
// NewRunner creates a new headless runner.
|
||||
func NewRunner(cfg Config) *Runner {
|
||||
return &Runner{
|
||||
cfg: cfg,
|
||||
logChan: make(chan string, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogChan returns the log channel for file logging.
|
||||
func (r *Runner) GetLogChan() <-chan string {
|
||||
return r.logChan
|
||||
}
|
||||
|
||||
// Run executes the full unattended installation flow.
|
||||
func (r *Runner) Run() error {
|
||||
r.log("Starting headless installation")
|
||||
|
||||
// 1. Parse compositor and terminal selections
|
||||
wm, err := r.parseWindowManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
terminal, err := r.parseTerminal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Build replace-configs map
|
||||
replaceConfigs, err := r.buildReplaceConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Detect OS
|
||||
r.log("Detecting operating system...")
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("OS detection failed: %w", err)
|
||||
}
|
||||
|
||||
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
||||
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
|
||||
|
||||
// 4. Create distribution instance
|
||||
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize distribution: %w", err)
|
||||
}
|
||||
|
||||
// 5. Detect dependencies
|
||||
r.log("Detecting dependencies...")
|
||||
fmt.Fprintln(os.Stdout, "Detecting dependencies...")
|
||||
dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dependency detection failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Apply include/exclude filters and build the disabled-items map.
|
||||
// Headless mode does not currently collect any explicit reinstall selections,
|
||||
// so keep reinstallItems nil instead of constructing an always-empty map.
|
||||
disabledItems, err := r.buildDisabledItems(dependencies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reinstallItems map[string]bool
|
||||
|
||||
// Print dependency summary
|
||||
fmt.Fprintln(os.Stdout, "\nDependencies:")
|
||||
for _, dep := range dependencies {
|
||||
marker := " "
|
||||
status := ""
|
||||
if disabledItems[dep.Name] {
|
||||
marker = " SKIP "
|
||||
status = "(disabled)"
|
||||
} else {
|
||||
switch dep.Status {
|
||||
case deps.StatusInstalled:
|
||||
marker = " OK "
|
||||
status = "(installed)"
|
||||
case deps.StatusMissing:
|
||||
marker = " NEW "
|
||||
status = "(will install)"
|
||||
case deps.StatusNeedsUpdate:
|
||||
marker = " UPD "
|
||||
status = "(will update)"
|
||||
case deps.StatusNeedsReinstall:
|
||||
marker = " RE "
|
||||
status = "(will reinstall)"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout)
|
||||
|
||||
// 6b. Require explicit confirmation unless --yes is set
|
||||
if !r.cfg.Yes {
|
||||
if replaceConfigs == nil {
|
||||
// --replace-configs-all
|
||||
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
|
||||
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
|
||||
} else if r.anyConfigEnabled(replaceConfigs) {
|
||||
var names []string
|
||||
for _, cliName := range orderedConfigNames {
|
||||
deployerKey := validConfigNames[cliName]
|
||||
if replaceConfigs[deployerKey] {
|
||||
names = append(names, deployerKey)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
|
||||
r.log("Aborted: --yes not set")
|
||||
return ErrConfirmationRequired
|
||||
}
|
||||
|
||||
// 7. Authenticate sudo
|
||||
sudoPassword, err := r.resolveSudoPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 8. Install packages
|
||||
fmt.Fprintln(os.Stdout, "Installing packages...")
|
||||
r.log("Starting package installation")
|
||||
|
||||
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||
|
||||
installErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(progressChan)
|
||||
installErr <- distro.InstallPackages(
|
||||
context.Background(),
|
||||
dependencies,
|
||||
wm,
|
||||
sudoPassword,
|
||||
reinstallItems,
|
||||
disabledItems,
|
||||
false, // skipGlobalUseFlags
|
||||
progressChan,
|
||||
)
|
||||
}()
|
||||
|
||||
// Consume progress messages and print them
|
||||
for msg := range progressChan {
|
||||
if msg.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||
} else if msg.Step != "" {
|
||||
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
|
||||
}
|
||||
if msg.LogOutput != "" {
|
||||
r.log(msg.LogOutput)
|
||||
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-installErr; err != nil {
|
||||
return fmt.Errorf("package installation failed: %w", err)
|
||||
}
|
||||
|
||||
// 9. Greeter setup (if dms-greeter was included)
|
||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||
compositorName := "niri"
|
||||
if wm == deps.WindowManagerHyprland {
|
||||
compositorName = "Hyprland"
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
||||
logFunc := func(line string) {
|
||||
r.log(line)
|
||||
fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
|
||||
}
|
||||
if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
|
||||
// Non-fatal, matching TUI behavior
|
||||
fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Deploy configurations
|
||||
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
||||
r.log("Starting configuration deployment")
|
||||
|
||||
deployer := config.NewConfigDeployer(r.logChan)
|
||||
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
wm,
|
||||
terminal,
|
||||
dependencies,
|
||||
replaceConfigs,
|
||||
reinstallItems,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration deployment failed: %w", err)
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.Deployed {
|
||||
msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
|
||||
if result.BackupPath != "" {
|
||||
msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, msg)
|
||||
}
|
||||
if result.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "\nInstallation complete!")
|
||||
r.log("Headless installation completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDisabledItems computes the set of dependencies that should be skipped
|
||||
// during installation, applying the --include-deps and --exclude-deps filters.
|
||||
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
|
||||
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
||||
disabledItems := make(map[string]bool)
|
||||
|
||||
// dms-greeter is opt-in (disabled by default), matching TUI behavior
|
||||
for i := range dependencies {
|
||||
if dependencies[i].Name == "dms-greeter" {
|
||||
disabledItems["dms-greeter"] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Process --include-deps (enable items that are disabled by default)
|
||||
for _, name := range r.cfg.IncludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !r.depExists(dependencies, name) {
|
||||
return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
|
||||
}
|
||||
delete(disabledItems, name)
|
||||
}
|
||||
|
||||
// Process --exclude-deps (disable items)
|
||||
for _, name := range r.cfg.ExcludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !r.depExists(dependencies, name) {
|
||||
return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
|
||||
}
|
||||
// Don't allow excluding DMS itself
|
||||
if name == "dms (DankMaterialShell)" {
|
||||
return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
|
||||
}
|
||||
disabledItems[name] = true
|
||||
}
|
||||
|
||||
return disabledItems, nil
|
||||
}
|
||||
|
||||
// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
|
||||
// flags into the map[string]bool consumed by the config deployer.
|
||||
//
|
||||
// Returns:
|
||||
// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
|
||||
// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
|
||||
// - a map with requested configs true, all others false for --replace-configs
|
||||
// - an error when both flags are set or an invalid config name is given
|
||||
func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
|
||||
hasSpecific := len(r.cfg.ReplaceConfigs) > 0
|
||||
if hasSpecific && r.cfg.ReplaceConfigsAll {
|
||||
return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
|
||||
}
|
||||
|
||||
if r.cfg.ReplaceConfigsAll {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build a map with all known configs explicitly set to false
|
||||
result := make(map[string]bool, len(validConfigNames))
|
||||
for _, cliName := range orderedConfigNames {
|
||||
result[validConfigNames[cliName]] = false
|
||||
}
|
||||
|
||||
// Enable only the requested configs
|
||||
for _, name := range r.cfg.ReplaceConfigs {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
deployerKey, ok := validConfigNames[strings.ToLower(name)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
|
||||
}
|
||||
result[deployerKey] = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Runner) log(message string) {
|
||||
select {
|
||||
case r.logChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
|
||||
switch strings.ToLower(r.cfg.Compositor) {
|
||||
case "niri":
|
||||
return deps.WindowManagerNiri, nil
|
||||
case "hyprland":
|
||||
return deps.WindowManagerHyprland, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) parseTerminal() (deps.Terminal, error) {
|
||||
switch strings.ToLower(r.cfg.Terminal) {
|
||||
case "ghostty":
|
||||
return deps.TerminalGhostty, nil
|
||||
case "kitty":
|
||||
return deps.TerminalKitty, nil
|
||||
case "alacritty":
|
||||
return deps.TerminalAlacritty, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) resolveSudoPassword() (string, error) {
|
||||
tool, err := privesc.Detect()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := privesc.CheckCached(context.Background()); err == nil {
|
||||
r.log(fmt.Sprintf("%s cache is valid, no password needed", tool.Name()))
|
||||
fmt.Fprintf(os.Stdout, "%s: using cached credentials\n", tool.Name())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
switch tool {
|
||||
case privesc.ToolSudo:
|
||||
return "", fmt.Errorf(
|
||||
"sudo authentication required but no cached credentials found\n" +
|
||||
"Options:\n" +
|
||||
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
||||
" 2. Configure passwordless sudo for your user",
|
||||
)
|
||||
case privesc.ToolDoas:
|
||||
return "", fmt.Errorf(
|
||||
"doas authentication required but no cached credentials found\n" +
|
||||
"Options:\n" +
|
||||
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
|
||||
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
|
||||
)
|
||||
case privesc.ToolRun0:
|
||||
return "", fmt.Errorf(
|
||||
"run0 authentication required but no cached credentials found\n" +
|
||||
"Configure a polkit rule granting your user passwordless privilege\n" +
|
||||
"(see `man polkit` for details on rules in /etc/polkit-1/rules.d/)",
|
||||
)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported privilege tool: %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
|
||||
for _, v := range m {
|
||||
if v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
|
||||
for _, dep := range dependencies {
|
||||
if dep.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
459
core/internal/headless/runner_test.go
Normal file
459
core/internal/headless/runner_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package headless
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
)
|
||||
|
||||
func TestParseWindowManager(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want deps.WindowManager
|
||||
wantErr bool
|
||||
}{
|
||||
{"niri lowercase", "niri", deps.WindowManagerNiri, false},
|
||||
{"niri mixed case", "Niri", deps.WindowManagerNiri, false},
|
||||
{"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false},
|
||||
{"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false},
|
||||
{"invalid", "sway", 0, true},
|
||||
{"empty", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{Compositor: tt.input})
|
||||
got, err := r.parseWindowManager()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("parseWindowManager() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want deps.Terminal
|
||||
wantErr bool
|
||||
}{
|
||||
{"ghostty lowercase", "ghostty", deps.TerminalGhostty, false},
|
||||
{"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false},
|
||||
{"kitty lowercase", "kitty", deps.TerminalKitty, false},
|
||||
{"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false},
|
||||
{"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false},
|
||||
{"invalid", "wezterm", 0, true},
|
||||
{"empty", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{Terminal: tt.input})
|
||||
got, err := r.parseTerminal()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("parseTerminal() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepExists(t *testing.T) {
|
||||
dependencies := []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
{Name: "ghostty", Status: deps.StatusMissing},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dep string
|
||||
want bool
|
||||
}{
|
||||
{"existing dep", "niri", true},
|
||||
{"existing dep with special chars", "dms (DankMaterialShell)", true},
|
||||
{"existing optional dep", "dms-greeter", true},
|
||||
{"non-existing dep", "firefox", false},
|
||||
{"empty name", "", false},
|
||||
}
|
||||
|
||||
r := NewRunner(Config{})
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := r.depExists(dependencies, tt.dep); got != tt.want {
|
||||
t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunner(t *testing.T) {
|
||||
cfg := Config{
|
||||
Compositor: "niri",
|
||||
Terminal: "ghostty",
|
||||
IncludeDeps: []string{"dms-greeter"},
|
||||
ExcludeDeps: []string{"some-pkg"},
|
||||
Yes: true,
|
||||
}
|
||||
r := NewRunner(cfg)
|
||||
|
||||
if r == nil {
|
||||
t.Fatal("NewRunner returned nil")
|
||||
}
|
||||
if r.cfg.Compositor != "niri" {
|
||||
t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri")
|
||||
}
|
||||
if r.cfg.Terminal != "ghostty" {
|
||||
t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty")
|
||||
}
|
||||
if !r.cfg.Yes {
|
||||
t.Error("cfg.Yes = false, want true")
|
||||
}
|
||||
if r.logChan == nil {
|
||||
t.Error("logChan is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLogChan(t *testing.T) {
|
||||
r := NewRunner(Config{})
|
||||
ch := r.GetLogChan()
|
||||
if ch == nil {
|
||||
t.Fatal("GetLogChan returned nil")
|
||||
}
|
||||
|
||||
// Verify the channel is readable by sending a message
|
||||
go func() {
|
||||
r.logChan <- "test message"
|
||||
}()
|
||||
msg := <-ch
|
||||
if msg != "test message" {
|
||||
t.Errorf("received %q, want %q", msg, "test message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
r := NewRunner(Config{})
|
||||
|
||||
// log should not block even if channel is full
|
||||
for i := 0; i < 1100; i++ {
|
||||
r.log("message")
|
||||
}
|
||||
// If we reach here without hanging, the non-blocking send works
|
||||
}
|
||||
|
||||
func TestRunRequiresYes(t *testing.T) {
|
||||
// Verify that ErrConfirmationRequired is a distinct sentinel error
|
||||
if ErrConfirmationRequired == nil {
|
||||
t.Fatal("ErrConfirmationRequired should not be nil")
|
||||
}
|
||||
expected := "confirmation required: pass --yes to proceed"
|
||||
if ErrConfirmationRequired.Error() != expected {
|
||||
t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigYesStoredCorrectly(t *testing.T) {
|
||||
// Yes=false (default) should be stored
|
||||
rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false})
|
||||
if rNo.cfg.Yes {
|
||||
t.Error("cfg.Yes = true, want false")
|
||||
}
|
||||
|
||||
// Yes=true should be stored
|
||||
rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true})
|
||||
if !rYes.cfg.Yes {
|
||||
t.Error("cfg.Yes = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidConfigNamesCompleteness(t *testing.T) {
|
||||
// orderedConfigNames and validConfigNames must stay in sync.
|
||||
if len(orderedConfigNames) != len(validConfigNames) {
|
||||
t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d",
|
||||
len(orderedConfigNames), len(validConfigNames))
|
||||
}
|
||||
|
||||
// Every entry in orderedConfigNames must exist in validConfigNames.
|
||||
for _, name := range orderedConfigNames {
|
||||
if _, ok := validConfigNames[name]; !ok {
|
||||
t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name)
|
||||
}
|
||||
}
|
||||
|
||||
// validConfigNames must have no extra keys not in orderedConfigNames.
|
||||
ordered := make(map[string]bool, len(orderedConfigNames))
|
||||
for _, name := range orderedConfigNames {
|
||||
ordered[name] = true
|
||||
}
|
||||
for key := range validConfigNames {
|
||||
if !ordered[key] {
|
||||
t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReplaceConfigs(t *testing.T) {
|
||||
allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
replaceConfigs []string
|
||||
replaceAll bool
|
||||
wantNil bool // expect nil (replace all)
|
||||
wantEnabled []string // deployer keys that should be true
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "neither flag set",
|
||||
wantNil: false,
|
||||
wantEnabled: nil, // all should be false
|
||||
},
|
||||
{
|
||||
name: "replace-configs-all",
|
||||
replaceAll: true,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "specific configs",
|
||||
replaceConfigs: []string{"niri", "ghostty"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri", "Ghostty"},
|
||||
},
|
||||
{
|
||||
name: "both flags set",
|
||||
replaceConfigs: []string{"niri"},
|
||||
replaceAll: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid config name",
|
||||
replaceConfigs: []string{"foo"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive",
|
||||
replaceConfigs: []string{"NIRI", "Ghostty"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri", "Ghostty"},
|
||||
},
|
||||
{
|
||||
name: "single config",
|
||||
replaceConfigs: []string{"kitty"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Kitty"},
|
||||
},
|
||||
{
|
||||
name: "whitespace entry",
|
||||
replaceConfigs: []string{" ", "niri"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri"},
|
||||
},
|
||||
{
|
||||
name: "duplicate entry",
|
||||
replaceConfigs: []string{"niri", "niri"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
ReplaceConfigs: tt.replaceConfigs,
|
||||
ReplaceConfigsAll: tt.replaceAll,
|
||||
})
|
||||
got, err := r.buildReplaceConfigs()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("buildReplaceConfigs() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("buildReplaceConfigs() = nil, want non-nil map")
|
||||
}
|
||||
|
||||
// All known deployer keys must be present
|
||||
for _, key := range allDeployerKeys {
|
||||
if _, exists := got[key]; !exists {
|
||||
t.Errorf("missing deployer key %q in result map", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Build enabled set for easy lookup
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, k := range tt.wantEnabled {
|
||||
enabledSet[k] = true
|
||||
}
|
||||
|
||||
for _, key := range allDeployerKeys {
|
||||
want := enabledSet[key]
|
||||
if got[key] != want {
|
||||
t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
Compositor: "niri",
|
||||
Terminal: "ghostty",
|
||||
ReplaceConfigs: []string{"niri", "ghostty"},
|
||||
ReplaceConfigsAll: false,
|
||||
})
|
||||
if len(r.cfg.ReplaceConfigs) != 2 {
|
||||
t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs))
|
||||
}
|
||||
if r.cfg.ReplaceConfigsAll {
|
||||
t.Error("ReplaceConfigsAll = true, want false")
|
||||
}
|
||||
|
||||
r2 := NewRunner(Config{
|
||||
Compositor: "niri",
|
||||
Terminal: "ghostty",
|
||||
ReplaceConfigsAll: true,
|
||||
})
|
||||
if !r2.cfg.ReplaceConfigsAll {
|
||||
t.Error("ReplaceConfigsAll = false, want true")
|
||||
}
|
||||
if len(r2.cfg.ReplaceConfigs) != 0 {
|
||||
t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDisabledItems(t *testing.T) {
|
||||
dependencies := []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
{Name: "ghostty", Status: deps.StatusMissing},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||
{Name: "waybar", Status: deps.StatusMissing},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
deps []deps.Dependency // nil means use the shared fixture
|
||||
wantErr bool
|
||||
errContains string // substring expected in error message
|
||||
wantDisabled []string // dep names that should be in disabledItems
|
||||
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
|
||||
}{
|
||||
{
|
||||
name: "no flags set, dms-greeter disabled by default",
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"niri", "ghostty", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include dms-greeter enables it",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "exclude a regular dep",
|
||||
excludeDeps: []string{"waybar"},
|
||||
wantDisabled: []string{"dms-greeter", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include unknown dep returns error",
|
||||
includeDeps: []string{"nonexistent"},
|
||||
wantErr: true,
|
||||
errContains: "--include-deps",
|
||||
},
|
||||
{
|
||||
name: "exclude unknown dep returns error",
|
||||
excludeDeps: []string{"nonexistent"},
|
||||
wantErr: true,
|
||||
errContains: "--exclude-deps",
|
||||
},
|
||||
{
|
||||
name: "exclude DMS itself is forbidden",
|
||||
excludeDeps: []string{"dms (DankMaterialShell)"},
|
||||
wantErr: true,
|
||||
errContains: "cannot exclude required package",
|
||||
},
|
||||
{
|
||||
name: "include and exclude same dep",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
excludeDeps: []string{"dms-greeter"},
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "whitespace entries are skipped",
|
||||
includeDeps: []string{" ", "dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "no dms-greeter in deps, nothing disabled by default",
|
||||
deps: []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
},
|
||||
wantEnabled: []string{"niri"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
IncludeDeps: tt.includeDeps,
|
||||
ExcludeDeps: tt.excludeDeps,
|
||||
})
|
||||
d := tt.deps
|
||||
if d == nil {
|
||||
d = dependencies
|
||||
}
|
||||
got, err := r.buildDisabledItems(d)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("buildDisabledItems() returned nil map, want non-nil")
|
||||
}
|
||||
|
||||
// Check expected disabled items
|
||||
for _, name := range tt.wantDisabled {
|
||||
if !got[name] {
|
||||
t.Errorf("expected %q to be disabled, but it is not", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check expected enabled items (should not be in the map or be false)
|
||||
for _, name := range tt.wantEnabled {
|
||||
if got[name] {
|
||||
t.Errorf("expected %q to NOT be disabled, but it is", name)
|
||||
}
|
||||
}
|
||||
|
||||
// If wantDisabled is empty, the map should have length 0
|
||||
if len(tt.wantDisabled) == 0 && len(got) != 0 {
|
||||
t.Errorf("expected empty disabledItems map, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/sblinch/kdl-go"
|
||||
"github.com/sblinch/kdl-go/document"
|
||||
)
|
||||
|
||||
@@ -292,7 +291,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
|
||||
parser := NewNiriParser(filepath.Dir(overridePath))
|
||||
parser.currentSource = overridePath
|
||||
|
||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
||||
doc, err := parseKDL(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -50,6 +50,103 @@ type NiriParser struct {
|
||||
conflictingConfigs map[string]*NiriKeyBinding
|
||||
}
|
||||
|
||||
func parseKDL(data []byte) (*document.Document, error) {
|
||||
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data))))
|
||||
}
|
||||
|
||||
func normalizeKDLBraces(input string) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(input))
|
||||
|
||||
var prev byte
|
||||
n := len(input)
|
||||
for i := 0; i < n; {
|
||||
c := input[i]
|
||||
|
||||
switch {
|
||||
case c == '"':
|
||||
end := findStringEnd(input, i)
|
||||
sb.WriteString(input[i:end])
|
||||
prev = '"'
|
||||
i = end
|
||||
case c == '/' && i+1 < n && input[i+1] == '/':
|
||||
end := findLineCommentEnd(input, i)
|
||||
sb.WriteString(input[i:end])
|
||||
prev = '\n'
|
||||
i = end
|
||||
case c == '/' && i+1 < n && input[i+1] == '*':
|
||||
end := findBlockCommentEnd(input, i)
|
||||
sb.WriteString(input[i:end])
|
||||
prev = '/'
|
||||
i = end
|
||||
case c == '{' && prev != 0 && !isBraceAdjacentSpace(prev):
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteByte(c)
|
||||
prev = c
|
||||
i++
|
||||
default:
|
||||
sb.WriteByte(c)
|
||||
prev = c
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func findStringEnd(s string, start int) int {
|
||||
n := len(s)
|
||||
for i := start + 1; i < n; {
|
||||
switch s[i] {
|
||||
case '\\':
|
||||
i += 2
|
||||
case '"':
|
||||
return i + 1
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func findLineCommentEnd(s string, start int) int {
|
||||
for i := start + 2; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func findBlockCommentEnd(s string, start int) int {
|
||||
n := len(s)
|
||||
depth := 1
|
||||
for i := start + 2; i < n && depth > 0; {
|
||||
switch {
|
||||
case i+1 < n && s[i] == '/' && s[i+1] == '*':
|
||||
depth++
|
||||
i += 2
|
||||
case i+1 < n && s[i] == '*' && s[i+1] == '/':
|
||||
depth--
|
||||
i += 2
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isBraceAdjacentSpace(b byte) bool {
|
||||
switch b {
|
||||
case ' ', '\t', '\n', '\r', '{':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewNiriParser(configDir string) *NiriParser {
|
||||
return &NiriParser{
|
||||
configDir: configDir,
|
||||
@@ -91,7 +188,7 @@ func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSec
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
||||
doc, err := parseKDL(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +256,7 @@ func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, erro
|
||||
return nil, fmt.Errorf("failed to read %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
||||
doc, err := parseKDL(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,74 @@ package providers
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
|
||||
config := `recent-windows {
|
||||
binds {
|
||||
Alt+Tab { next-window scope="output"; }
|
||||
Alt+Shift+Tab { previous-window scope="output"; }
|
||||
Alt+grave { next-window filter="app-id"; }
|
||||
Alt+Shift+grave { previous-window filter="app-id"; }
|
||||
Alt+Escape { next-window scope="all"; }
|
||||
Alt+Shift+Escape{ previous-window scope="all"; }
|
||||
}
|
||||
}
|
||||
`
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
result, err := ParseNiriKeys(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNiriKeys failed on valid niri config: %v", err)
|
||||
}
|
||||
|
||||
var found *NiriKeyBinding
|
||||
for i := range result.Section.Keybinds {
|
||||
kb := &result.Section.Keybinds[i]
|
||||
if kb.Key == "Escape" && slices.Contains(kb.Mods, "Alt") && slices.Contains(kb.Mods, "Shift") {
|
||||
found = kb
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("Alt+Shift+Escape bind missing — '{' without preceding space was not handled")
|
||||
}
|
||||
if found.Action != "previous-window" {
|
||||
t.Errorf("Action = %q, want %q", found.Action, "previous-window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKDLBraces(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{"already spaced", "node { child }\n", "node { child }\n"},
|
||||
{"missing space", "node{ child }\n", "node { child }\n"},
|
||||
{"niri keybind", "Alt+Shift+Escape{ previous-window; }", "Alt+Shift+Escape { previous-window; }"},
|
||||
{"brace inside string", `node "a{b" { child }`, `node "a{b" { child }`},
|
||||
{"brace in line comment", "// foo{bar\nnode { }", "// foo{bar\nnode { }"},
|
||||
{"brace in block comment", "/* foo{bar */ node{ }", "/* foo{bar */ node { }"},
|
||||
{"escaped quote in string", `node "a\"b{c" { }`, `node "a\"b{c" { }`},
|
||||
{"leading brace", "{ child }", "{ child }"},
|
||||
{"nested missing space", "a{b{ c }}", "a {b { c }}"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := normalizeKDLBraces(tc.in)
|
||||
if got != tc.out {
|
||||
t.Errorf("normalizeKDLBraces(%q) = %q, want %q", tc.in, got, tc.out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriParseKeyCombo(t *testing.T) {
|
||||
tests := []struct {
|
||||
combo string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -21,7 +22,16 @@ type FileLogger struct {
|
||||
|
||||
func NewFileLogger() (*FileLogger, error) {
|
||||
timestamp := time.Now().Unix()
|
||||
logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp)
|
||||
|
||||
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp.
|
||||
logDir := os.Getenv("DANKINSTALL_LOG_DIR")
|
||||
if logDir == "" {
|
||||
logDir = "/tmp"
|
||||
}
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp))
|
||||
|
||||
file, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -80,16 +81,18 @@ type lockscreenPamResolver struct {
|
||||
|
||||
func defaultSyncDeps() syncDeps {
|
||||
return syncDeps{
|
||||
pamDir: "/etc/pam.d",
|
||||
greetdPath: GreetdPamPath,
|
||||
dankshellPath: DankshellPamPath,
|
||||
dankshellU2fPath: DankshellU2FPamPath,
|
||||
isNixOS: IsNixOS,
|
||||
readFile: os.ReadFile,
|
||||
stat: os.Stat,
|
||||
createTemp: os.CreateTemp,
|
||||
removeFile: os.Remove,
|
||||
runSudoCmd: runSudoCmd,
|
||||
pamDir: "/etc/pam.d",
|
||||
greetdPath: GreetdPamPath,
|
||||
dankshellPath: DankshellPamPath,
|
||||
dankshellU2fPath: DankshellU2FPamPath,
|
||||
isNixOS: IsNixOS,
|
||||
readFile: os.ReadFile,
|
||||
stat: os.Stat,
|
||||
createTemp: os.CreateTemp,
|
||||
removeFile: os.Remove,
|
||||
runSudoCmd: func(password, command string, args ...string) error {
|
||||
return privesc.Run(context.Background(), password, append([]string{command}, args...)...)
|
||||
},
|
||||
pamModuleExists: pamModuleExists,
|
||||
fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser,
|
||||
}
|
||||
@@ -869,24 +872,3 @@ func fingerprintAuthAvailableForUser(username string) bool {
|
||||
}
|
||||
return hasEnrolledFingerprintOutput(string(out))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
385
core/internal/privesc/privesc.go
Normal file
385
core/internal/privesc/privesc.go
Normal 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
|
||||
}
|
||||
@@ -215,31 +215,34 @@ func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential
|
||||
callback: callback,
|
||||
}
|
||||
|
||||
if timer, exists := b.debounceTimers[id]; exists {
|
||||
timer.Reset(200 * time.Millisecond)
|
||||
} else {
|
||||
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
|
||||
b.debounceMutex.Lock()
|
||||
pending, exists := b.debouncePending[id]
|
||||
if exists {
|
||||
delete(b.debouncePending, id)
|
||||
}
|
||||
b.debounceMutex.Unlock()
|
||||
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
err := b.setBrightnessImmediateWithExponent(id, pending.percent)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to set brightness for %s: %v", id, err)
|
||||
}
|
||||
|
||||
if pending.callback != nil {
|
||||
pending.callback()
|
||||
}
|
||||
})
|
||||
if existing, exists := b.debounceTimers[id]; exists {
|
||||
if existing.Stop() {
|
||||
b.debounceWg.Done()
|
||||
}
|
||||
}
|
||||
|
||||
b.debounceWg.Add(1)
|
||||
b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
|
||||
defer b.debounceWg.Done()
|
||||
|
||||
b.debounceMutex.Lock()
|
||||
pending, hasPending := b.debouncePending[id]
|
||||
delete(b.debouncePending, id)
|
||||
delete(b.debounceTimers, id)
|
||||
b.debounceMutex.Unlock()
|
||||
|
||||
if !hasPending {
|
||||
return
|
||||
}
|
||||
|
||||
if err := b.setBrightnessImmediateWithExponent(id, pending.percent); err != nil {
|
||||
log.Debugf("Failed to set brightness for %s: %v", id, err)
|
||||
}
|
||||
|
||||
if pending.callback != nil {
|
||||
pending.callback()
|
||||
}
|
||||
})
|
||||
b.debounceMutex.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -490,5 +493,19 @@ func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
|
||||
return percent
|
||||
}
|
||||
|
||||
func (b *DDCBackend) WaitPending() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.debounceWg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
log.Debug("WaitPending timed out waiting for DDC writes")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *DDCBackend) Close() {
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ type DDCBackend struct {
|
||||
debounceMutex sync.Mutex
|
||||
debounceTimers map[string]*time.Timer
|
||||
debouncePending map[string]ddcPendingSet
|
||||
debounceWg sync.WaitGroup
|
||||
}
|
||||
|
||||
type ddcPendingSet struct {
|
||||
|
||||
@@ -158,18 +158,26 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo
|
||||
|
||||
channel := frequencyToChannel(freq)
|
||||
|
||||
isConnected := ssid == currentSSID && bssid == currentBSSID
|
||||
rate := maxBitrate / 1000
|
||||
if isConnected {
|
||||
if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
|
||||
rate = devBitrate / 1000
|
||||
}
|
||||
}
|
||||
|
||||
network := WiFiNetwork{
|
||||
SSID: ssid,
|
||||
BSSID: bssid,
|
||||
Signal: strength,
|
||||
Secured: secured,
|
||||
Enterprise: enterprise,
|
||||
Connected: ssid == currentSSID && bssid == currentBSSID,
|
||||
Connected: isConnected,
|
||||
Saved: savedSSIDs[ssid],
|
||||
Autoconnect: autoconnectMap[ssid],
|
||||
Frequency: freq,
|
||||
Mode: modeStr,
|
||||
Rate: maxBitrate / 1000,
|
||||
Rate: rate,
|
||||
Channel: channel,
|
||||
}
|
||||
|
||||
@@ -514,19 +522,27 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
|
||||
channel := frequencyToChannel(freq)
|
||||
|
||||
isConnected := ssid == currentSSID
|
||||
rate := maxBitrate / 1000
|
||||
if isConnected {
|
||||
if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
|
||||
rate = devBitrate / 1000
|
||||
}
|
||||
}
|
||||
|
||||
network := WiFiNetwork{
|
||||
SSID: ssid,
|
||||
BSSID: bssid,
|
||||
Signal: strength,
|
||||
Secured: secured,
|
||||
Enterprise: enterprise,
|
||||
Connected: ssid == currentSSID,
|
||||
Connected: isConnected,
|
||||
Saved: savedSSIDs[ssid],
|
||||
Autoconnect: autoconnectMap[ssid],
|
||||
Hidden: hiddenSSIDs[ssid],
|
||||
Frequency: freq,
|
||||
Mode: modeStr,
|
||||
Rate: maxBitrate / 1000,
|
||||
Rate: rate,
|
||||
Channel: channel,
|
||||
}
|
||||
|
||||
@@ -1062,19 +1078,27 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
|
||||
channel := frequencyToChannel(freq)
|
||||
|
||||
isConnected := connected && apSSID == ssid
|
||||
rate := maxBitrate / 1000
|
||||
if isConnected {
|
||||
if devBitrate, err := devInfo.wireless.GetPropertyBitrate(); err == nil && devBitrate > 0 {
|
||||
rate = devBitrate / 1000
|
||||
}
|
||||
}
|
||||
|
||||
network := WiFiNetwork{
|
||||
SSID: apSSID,
|
||||
BSSID: apBSSID,
|
||||
Signal: strength,
|
||||
Secured: secured,
|
||||
Enterprise: enterprise,
|
||||
Connected: connected && apSSID == ssid,
|
||||
Connected: isConnected,
|
||||
Saved: savedSSIDs[apSSID],
|
||||
Autoconnect: autoconnectMap[apSSID],
|
||||
Hidden: hiddenSSIDs[apSSID],
|
||||
Frequency: freq,
|
||||
Mode: modeStr,
|
||||
Rate: maxBitrate / 1000,
|
||||
Rate: rate,
|
||||
Channel: channel,
|
||||
Device: name,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
||||
@@ -72,6 +73,7 @@ var clipboardManager *clipboard.Manager
|
||||
var dbusManager *serverDbus.Manager
|
||||
var wlContext *wlcontext.SharedContext
|
||||
var themeModeManager *thememode.Manager
|
||||
var trayRecoveryManager *trayrecovery.Manager
|
||||
var locationManager *location.Manager
|
||||
var geoClientInstance geolocation.Client
|
||||
|
||||
@@ -394,6 +396,18 @@ func InitializeThemeModeManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeTrayRecoveryManager() error {
|
||||
manager, err := trayrecovery.NewManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trayRecoveryManager = manager
|
||||
|
||||
log.Info("TrayRecovery manager initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeLocationManager(geoClient geolocation.Client) error {
|
||||
manager, err := location.NewManager(geoClient)
|
||||
if err != nil {
|
||||
@@ -1325,6 +1339,9 @@ func cleanupManagers() {
|
||||
if themeModeManager != nil {
|
||||
themeModeManager.Close()
|
||||
}
|
||||
if trayRecoveryManager != nil {
|
||||
trayRecoveryManager.Close()
|
||||
}
|
||||
if wlContext != nil {
|
||||
wlContext.Close()
|
||||
}
|
||||
@@ -1610,6 +1627,18 @@ func Start(printDocs bool) error {
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-loginctlReady
|
||||
if loginctlManager == nil {
|
||||
return
|
||||
}
|
||||
if err := InitializeTrayRecoveryManager(); err != nil {
|
||||
log.Warnf("TrayRecovery manager unavailable: %v", err)
|
||||
} else {
|
||||
trayRecoveryManager.WatchLoginctl(loginctlManager)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
geoClient := geolocation.NewClient()
|
||||
geoClientInstance = geoClient
|
||||
|
||||
115
core/internal/server/trayrecovery/manager.go
Normal file
115
core/internal/server/trayrecovery/manager.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package trayrecovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const resumeDelay = 3 * time.Second
|
||||
|
||||
type Manager struct {
|
||||
conn *dbus.Conn
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to session bus: %w", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
conn: conn,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Only run a startup scan when the system has been suspended at least once.
|
||||
// On a fresh boot CLOCK_BOOTTIME ≈ CLOCK_MONOTONIC (difference ~0).
|
||||
// After any suspend/resume cycle the difference grows by the time spent
|
||||
// sleeping. This avoids duplicate registrations on normal boot where apps
|
||||
// are still starting up and will register their own tray icons shortly.
|
||||
if timeSuspended() > 5*time.Second {
|
||||
go m.scheduleRecovery()
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// WatchLoginctl subscribes to loginctl session state changes and triggers
|
||||
// tray recovery after resume from suspend (PrepareForSleep false transition).
|
||||
// This handles the case where the process survives suspend.
|
||||
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
||||
ch := lm.Subscribe("tray-recovery")
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
defer lm.Unsubscribe("tray-recovery")
|
||||
|
||||
wasSleeping := false
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case state, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if state.PreparingForSleep {
|
||||
wasSleeping = true
|
||||
continue
|
||||
}
|
||||
if wasSleeping {
|
||||
wasSleeping = false
|
||||
go m.scheduleRecovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) scheduleRecovery() {
|
||||
select {
|
||||
case <-time.After(resumeDelay):
|
||||
m.recoverTrayItems()
|
||||
case <-m.stopChan:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
default:
|
||||
close(m.stopChan)
|
||||
}
|
||||
m.wg.Wait()
|
||||
if m.conn != nil {
|
||||
m.conn.Close()
|
||||
}
|
||||
log.Info("TrayRecovery manager closed")
|
||||
}
|
||||
|
||||
// timeSuspended returns how long the system has spent in suspend since boot.
|
||||
// It is the difference between CLOCK_BOOTTIME (includes suspend) and
|
||||
// CLOCK_MONOTONIC (excludes suspend).
|
||||
func timeSuspended() time.Duration {
|
||||
var bt, mt unix.Timespec
|
||||
if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &bt); err != nil {
|
||||
return 0
|
||||
}
|
||||
if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &mt); err != nil {
|
||||
return 0
|
||||
}
|
||||
diff := (bt.Sec-mt.Sec)*int64(time.Second) + (bt.Nsec - mt.Nsec)
|
||||
if diff < 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(diff)
|
||||
}
|
||||
262
core/internal/server/trayrecovery/recovery.go
Normal file
262
core/internal/server/trayrecovery/recovery.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package trayrecovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
sniWatcherDest = "org.kde.StatusNotifierWatcher"
|
||||
sniWatcherPath = "/StatusNotifierWatcher"
|
||||
sniWatcherIface = "org.kde.StatusNotifierWatcher"
|
||||
sniItemIface = "org.kde.StatusNotifierItem"
|
||||
dbusIface = "org.freedesktop.DBus"
|
||||
propsIface = "org.freedesktop.DBus.Properties"
|
||||
probeTimeout = 300 * time.Millisecond
|
||||
connProbeTimeout = 150 * time.Millisecond
|
||||
batchSize = 30
|
||||
)
|
||||
|
||||
var excludedPrefixes = []string{
|
||||
"org.freedesktop.",
|
||||
"org.gnome.",
|
||||
"org.kde.StatusNotifier",
|
||||
"com.canonical.AppMenu",
|
||||
"org.mpris.",
|
||||
"org.pipewire.",
|
||||
"org.pulseaudio",
|
||||
"fi.epitaph",
|
||||
"quickshell",
|
||||
"org.kde.quickshell",
|
||||
}
|
||||
|
||||
func (m *Manager) recoverTrayItems() {
|
||||
registeredItems := m.getRegisteredItems()
|
||||
allNames := m.getDBusNames()
|
||||
if allNames == nil {
|
||||
return
|
||||
}
|
||||
|
||||
registeredConnIDs := m.buildRegisteredConnIDs(registeredItems)
|
||||
|
||||
count := len(registeredItems)
|
||||
log.Infof("TrayRecoveryService: scanning DBus for unregistered SNI items (%d already registered)...", count)
|
||||
|
||||
m.scanWellKnownNames(allNames, registeredItems, registeredConnIDs)
|
||||
m.scanConnectionIDs(allNames, registeredItems, registeredConnIDs)
|
||||
}
|
||||
|
||||
func (m *Manager) getRegisteredItems() []string {
|
||||
obj := m.conn.Object(sniWatcherDest, sniWatcherPath)
|
||||
variant, err := obj.GetProperty(sniWatcherIface + ".RegisteredStatusNotifierItems")
|
||||
if err != nil {
|
||||
log.Warnf("TrayRecoveryService: failed to get registered items: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := variant.Value().(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, elem := range v {
|
||||
if s, ok := elem.(string); ok {
|
||||
items = append(items, s)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) getDBusNames() []string {
|
||||
var names []string
|
||||
err := m.conn.BusObject().Call(dbusIface+".ListNames", 0).Store(&names)
|
||||
if err != nil {
|
||||
log.Warnf("TrayRecoveryService: failed to list bus names: %v", err)
|
||||
return nil
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *Manager) getNameOwner(name string) string {
|
||||
var owner string
|
||||
err := m.conn.BusObject().Call(dbusIface+".GetNameOwner", 0, name).Store(&owner)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
// buildRegisteredConnIDs resolves every registered SNI item (well-known name
|
||||
// or :1.xxx connection ID) to a canonical connection ID. This prevents
|
||||
// duplicates in both directions.
|
||||
func (m *Manager) buildRegisteredConnIDs(registeredItems []string) map[string]bool {
|
||||
connIDs := make(map[string]bool, len(registeredItems))
|
||||
for _, item := range registeredItems {
|
||||
name := extractName(item)
|
||||
if strings.HasPrefix(name, ":1.") {
|
||||
connIDs[name] = true
|
||||
} else {
|
||||
owner := m.getNameOwner(name)
|
||||
if owner != "" {
|
||||
connIDs[owner] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return connIDs
|
||||
}
|
||||
|
||||
// scanWellKnownNames probes well-known names (e.g. DinoX, nm-applet) for
|
||||
// unregistered SNI items and re-registers them.
|
||||
func (m *Manager) scanWellKnownNames(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) {
|
||||
registeredRaw := strings.Join(registeredItems, "\n")
|
||||
|
||||
for _, name := range allNames {
|
||||
if strings.HasPrefix(name, ":") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(registeredRaw, name) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if this name's connection ID is already in the registered set
|
||||
// (handles the case where the app registered via connection ID instead)
|
||||
connForName := m.getNameOwner(name)
|
||||
if connForName != "" && registeredConnIDs[connForName] {
|
||||
continue
|
||||
}
|
||||
|
||||
if isExcludedName(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
short := shortName(name)
|
||||
objectPaths := []string{
|
||||
"/StatusNotifierItem",
|
||||
"/org/ayatana/NotificationItem/" + short,
|
||||
}
|
||||
|
||||
for _, objPath := range objectPaths {
|
||||
if m.probeSNI(name, objPath, probeTimeout) {
|
||||
m.registerSNI(name)
|
||||
// Update set so the connection-ID section won't double-register this app
|
||||
if connForName != "" {
|
||||
registeredConnIDs[connForName] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanConnectionIDs probes all :1.xxx connections in parallel for unregistered
|
||||
// SNI items (e.g. Vesktop, Electron apps). Most non-SNI connections return an
|
||||
// error instantly, so this is fast.
|
||||
func (m *Manager) scanConnectionIDs(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) {
|
||||
registeredRaw := strings.Join(registeredItems, "\n")
|
||||
registeredLower := strings.ToLower(registeredRaw)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, batchSize)
|
||||
|
||||
for _, name := range allNames {
|
||||
if !strings.HasPrefix(name, ":1.") {
|
||||
continue
|
||||
}
|
||||
if registeredConnIDs[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(conn string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
sniID := m.getSNIId(conn, connProbeTimeout)
|
||||
if sniID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if an item with the same Id is already registered (case-insensitive)
|
||||
if strings.Contains(registeredLower, strings.ToLower(sniID)) {
|
||||
return
|
||||
}
|
||||
|
||||
m.registerSNI(conn)
|
||||
log.Infof("TrayRecovery: re-registered %s (Id: %s)", conn, sniID)
|
||||
}(name)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (m *Manager) probeSNI(dest, path string, timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
obj := m.conn.Object(dest, dbus.ObjectPath(path))
|
||||
var props map[string]dbus.Variant
|
||||
err := obj.CallWithContext(ctx, propsIface+".GetAll", 0, sniItemIface).Store(&props)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, hasID := props["Id"]
|
||||
return hasID
|
||||
}
|
||||
|
||||
func (m *Manager) getSNIId(dest string, timeout time.Duration) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
obj := m.conn.Object(dest, "/StatusNotifierItem")
|
||||
var variant dbus.Variant
|
||||
err := obj.CallWithContext(ctx, propsIface+".Get", 0, sniItemIface, "Id").Store(&variant)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
id, _ := variant.Value().(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func (m *Manager) registerSNI(name string) {
|
||||
obj := m.conn.Object(sniWatcherDest, sniWatcherPath)
|
||||
call := obj.Call(sniWatcherIface+".RegisterStatusNotifierItem", 0, name)
|
||||
if call.Err != nil {
|
||||
log.Warnf("TrayRecovery: failed to register %s: %v", name, call.Err)
|
||||
return
|
||||
}
|
||||
log.Infof("TrayRecovery: re-registered %s", name)
|
||||
}
|
||||
|
||||
func extractName(item string) string {
|
||||
if idx := strings.IndexByte(item, '/'); idx != -1 {
|
||||
return item[:idx]
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func shortName(name string) string {
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func isExcludedName(name string) bool {
|
||||
for _, prefix := range excludedPrefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package wayland
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -73,7 +76,10 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
|
||||
m.post(func() {
|
||||
log.Info("Gamma control enabled at startup")
|
||||
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||
if err := m.setupOutputControls(m.availableOutputs, gammaMgr); err != nil {
|
||||
m.availOutputsMu.RLock()
|
||||
outs := slices.Clone(m.availableOutputs)
|
||||
m.availOutputsMu.RUnlock()
|
||||
if err := m.setupOutputControls(outs, gammaMgr); err != nil {
|
||||
log.Errorf("Failed to initialize gamma controls: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -170,6 +176,7 @@ func (m *Manager) setupRegistry() error {
|
||||
})
|
||||
if gammaMgr != nil {
|
||||
outputs = append(outputs, output)
|
||||
m.addAvailableOutput(output)
|
||||
}
|
||||
m.outputRegNames.Store(outputID, e.Name)
|
||||
|
||||
@@ -204,6 +211,11 @@ func (m *Manager) setupRegistry() error {
|
||||
}
|
||||
if foundOut.gammaControl != nil {
|
||||
foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
|
||||
foundOut.gammaControl = nil
|
||||
}
|
||||
m.removeAvailableOutput(foundOut.output)
|
||||
if foundOut.output != nil && !foundOut.output.IsZombie() {
|
||||
_ = foundOut.output.Release()
|
||||
}
|
||||
m.outputs.Delete(foundID)
|
||||
|
||||
@@ -288,14 +300,28 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() {
|
||||
ctrl.Destroy()
|
||||
}
|
||||
out.gammaControl = nil
|
||||
out.failed = true
|
||||
out.rampSize = 0
|
||||
out.retryCount++
|
||||
out.lastFailTime = time.Now()
|
||||
|
||||
if !m.outputStillValid(out) {
|
||||
return
|
||||
}
|
||||
|
||||
backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
|
||||
time.AfterFunc(backoff, func() {
|
||||
m.post(func() {
|
||||
if !m.outputStillValid(out) {
|
||||
return
|
||||
}
|
||||
if _, stillTracked := m.outputs.Load(outputID); !stillTracked {
|
||||
return
|
||||
}
|
||||
m.recreateOutputControl(out)
|
||||
})
|
||||
})
|
||||
@@ -303,12 +329,75 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) addAvailableOutput(o *wlclient.Output) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
m.availOutputsMu.Lock()
|
||||
defer m.availOutputsMu.Unlock()
|
||||
if slices.Contains(m.availableOutputs, o) {
|
||||
return
|
||||
}
|
||||
m.availableOutputs = append(m.availableOutputs, o)
|
||||
}
|
||||
|
||||
func (m *Manager) removeAvailableOutput(o *wlclient.Output) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
m.availOutputsMu.Lock()
|
||||
defer m.availOutputsMu.Unlock()
|
||||
m.availableOutputs = slices.DeleteFunc(m.availableOutputs, func(existing *wlclient.Output) bool {
|
||||
return existing == o
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) outputStillValid(out *outputState) bool {
|
||||
switch {
|
||||
case out == nil:
|
||||
return false
|
||||
case out.output == nil:
|
||||
return false
|
||||
case out.output.IsZombie():
|
||||
return false
|
||||
}
|
||||
m.availOutputsMu.RLock()
|
||||
defer m.availOutputsMu.RUnlock()
|
||||
return slices.Contains(m.availableOutputs, out.output)
|
||||
}
|
||||
|
||||
func isConnectionDeadErr(err error) bool {
|
||||
switch {
|
||||
case err == nil:
|
||||
return false
|
||||
case errors.Is(err, syscall.EPIPE):
|
||||
return true
|
||||
case errors.Is(err, syscall.ECONNRESET):
|
||||
return true
|
||||
case errors.Is(err, syscall.EBADF):
|
||||
return true
|
||||
case errors.Is(err, io.EOF):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) addOutputControl(output *wlclient.Output) error {
|
||||
switch {
|
||||
case m.connectionDead.Load():
|
||||
return nil
|
||||
case output == nil || output.IsZombie():
|
||||
return nil
|
||||
}
|
||||
|
||||
outputID := output.ID()
|
||||
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||
|
||||
control, err := gammaMgr.GetGammaControl(output)
|
||||
if err != nil {
|
||||
if isConnectionDeadErr(err) {
|
||||
m.markConnectionDead(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -329,26 +418,37 @@ func (m *Manager) recreateOutputControl(out *outputState) error {
|
||||
enabled := m.config.Enabled
|
||||
m.configMutex.RUnlock()
|
||||
|
||||
if !enabled || !m.controlsInitialized {
|
||||
switch {
|
||||
case m.connectionDead.Load():
|
||||
return nil
|
||||
case !enabled || !m.controlsInitialized:
|
||||
return nil
|
||||
case out.isVirtual:
|
||||
return nil
|
||||
case out.retryCount >= 10:
|
||||
return nil
|
||||
case !m.outputStillValid(out):
|
||||
return nil
|
||||
}
|
||||
if _, ok := m.outputs.Load(out.id); !ok {
|
||||
return nil
|
||||
}
|
||||
if out.isVirtual {
|
||||
return nil
|
||||
}
|
||||
if out.retryCount >= 10 {
|
||||
return nil
|
||||
}
|
||||
|
||||
gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||
if !ok {
|
||||
return fmt.Errorf("no gamma manager")
|
||||
}
|
||||
|
||||
if existing, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && existing != nil && !existing.IsZombie() {
|
||||
existing.Destroy()
|
||||
out.gammaControl = nil
|
||||
}
|
||||
|
||||
control, err := gammaMgr.GetGammaControl(out.output)
|
||||
if err != nil {
|
||||
if isConnectionDeadErr(err) {
|
||||
m.markConnectionDead(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -358,6 +458,13 @@ func (m *Manager) recreateOutputControl(out *outputState) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) markConnectionDead(err error) {
|
||||
if m.connectionDead.Swap(true) {
|
||||
return
|
||||
}
|
||||
log.Errorf("gamma: wayland connection appears dead (%v); pausing gamma operations", err)
|
||||
}
|
||||
|
||||
func (m *Manager) recalcSchedule(now time.Time) {
|
||||
m.configMutex.RLock()
|
||||
config := m.config
|
||||
@@ -690,11 +797,12 @@ func (m *Manager) applyGamma(temp int) {
|
||||
gamma := m.config.Gamma
|
||||
m.configMutex.RUnlock()
|
||||
|
||||
if !m.controlsInitialized {
|
||||
switch {
|
||||
case m.connectionDead.Load():
|
||||
return
|
||||
}
|
||||
|
||||
if m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma {
|
||||
case !m.controlsInitialized:
|
||||
return
|
||||
case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -714,7 +822,14 @@ func (m *Manager) applyGamma(temp int) {
|
||||
var jobs []job
|
||||
|
||||
for _, out := range outs {
|
||||
if out.failed || out.rampSize == 0 {
|
||||
switch {
|
||||
case out.failed:
|
||||
continue
|
||||
case out.rampSize == 0:
|
||||
continue
|
||||
case out.gammaControl == nil:
|
||||
continue
|
||||
case !m.outputStillValid(out):
|
||||
continue
|
||||
}
|
||||
ramp := GenerateGammaRamp(out.rampSize, temp, gamma)
|
||||
@@ -732,18 +847,16 @@ func (m *Manager) applyGamma(temp int) {
|
||||
}
|
||||
|
||||
for _, j := range jobs {
|
||||
if err := m.setGammaBytes(j.out, j.data); err != nil {
|
||||
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
||||
j.out.failed = true
|
||||
j.out.rampSize = 0
|
||||
outID := j.out.id
|
||||
time.AfterFunc(300*time.Millisecond, func() {
|
||||
m.post(func() {
|
||||
if out, ok := m.outputs.Load(outID); ok && out.failed {
|
||||
m.recreateOutputControl(out)
|
||||
}
|
||||
})
|
||||
})
|
||||
err := m.setGammaBytes(j.out, j.data)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
||||
j.out.failed = true
|
||||
j.out.rampSize = 0
|
||||
if isConnectionDeadErr(err) {
|
||||
m.markConnectionDead(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,6 +865,14 @@ func (m *Manager) applyGamma(temp int) {
|
||||
}
|
||||
|
||||
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
||||
if out.gammaControl == nil {
|
||||
return fmt.Errorf("no gamma control")
|
||||
}
|
||||
ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
|
||||
if !ok || ctrl == nil || ctrl.IsZombie() {
|
||||
return fmt.Errorf("gamma control invalid")
|
||||
}
|
||||
|
||||
fd, err := MemfdCreate("gamma-ramp", 0)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -774,7 +895,6 @@ func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
||||
}
|
||||
syscall.Seek(fd, 0, 0)
|
||||
|
||||
ctrl := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
|
||||
return ctrl.SetGamma(fd)
|
||||
}
|
||||
|
||||
@@ -882,10 +1002,10 @@ func (m *Manager) dbusMonitor() {
|
||||
}
|
||||
|
||||
func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
||||
if sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep" {
|
||||
switch {
|
||||
case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep":
|
||||
return
|
||||
}
|
||||
if len(sig.Body) == 0 {
|
||||
case len(sig.Body) == 0:
|
||||
return
|
||||
}
|
||||
preparing, ok := sig.Body[0].(bool)
|
||||
@@ -899,27 +1019,34 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
||||
return
|
||||
}
|
||||
time.AfterFunc(500*time.Millisecond, func() {
|
||||
m.post(func() {
|
||||
m.configMutex.RLock()
|
||||
stillEnabled := m.config.Enabled
|
||||
m.configMutex.RUnlock()
|
||||
if !stillEnabled || !m.controlsInitialized {
|
||||
return
|
||||
}
|
||||
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
||||
if out.gammaControl != nil {
|
||||
out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
|
||||
out.gammaControl = nil
|
||||
}
|
||||
out.retryCount = 0
|
||||
out.failed = false
|
||||
m.recreateOutputControl(out)
|
||||
return true
|
||||
})
|
||||
})
|
||||
m.post(m.handleResume)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) handleResume() {
|
||||
m.configMutex.RLock()
|
||||
stillEnabled := m.config.Enabled
|
||||
m.configMutex.RUnlock()
|
||||
|
||||
switch {
|
||||
case !stillEnabled:
|
||||
return
|
||||
case !m.controlsInitialized:
|
||||
return
|
||||
case m.connectionDead.Load():
|
||||
return
|
||||
}
|
||||
|
||||
// Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma
|
||||
// ramp to DRM on resume; gamma_control objects stay valid. We just need
|
||||
// to force a resend so the schedule catches up with the current time of
|
||||
// day — the original #1235 regression was caused by lastAppliedTemp
|
||||
// matching and the send being skipped.
|
||||
m.recalcSchedule(time.Now())
|
||||
m.lastAppliedTemp = 0
|
||||
m.applyCurrentTemp("resume")
|
||||
}
|
||||
|
||||
func (m *Manager) triggerUpdate() {
|
||||
select {
|
||||
case m.updateTrigger <- struct{}{}:
|
||||
@@ -1058,7 +1185,10 @@ func (m *Manager) SetEnabled(enabled bool) {
|
||||
case enabled && !m.controlsInitialized:
|
||||
m.post(func() {
|
||||
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||
if err := m.setupOutputControls(m.availableOutputs, gammaMgr); err != nil {
|
||||
m.availOutputsMu.RLock()
|
||||
outs := slices.Clone(m.availableOutputs)
|
||||
m.availOutputsMu.RUnlock()
|
||||
if err := m.setupOutputControls(outs, gammaMgr); err != nil {
|
||||
log.Errorf("gamma: failed to create controls: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package wayland
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||
@@ -71,9 +72,11 @@ type Manager struct {
|
||||
registry *wlclient.Registry
|
||||
gammaControl any
|
||||
availableOutputs []*wlclient.Output
|
||||
availOutputsMu sync.RWMutex
|
||||
outputRegNames syncmap.Map[uint32, uint32]
|
||||
outputs syncmap.Map[uint32, *outputState]
|
||||
controlsInitialized bool
|
||||
connectionDead atomic.Bool
|
||||
|
||||
cmdq chan cmd
|
||||
alive bool
|
||||
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -42,6 +43,9 @@ type Model struct {
|
||||
sudoPassword string
|
||||
existingConfigs []ExistingConfigInfo
|
||||
fingerprintFailed bool
|
||||
|
||||
availablePrivesc []privesc.Tool
|
||||
selectedPrivesc int
|
||||
}
|
||||
|
||||
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)
|
||||
case StateGentooGCCCheck:
|
||||
return m.updateGentooGCCCheckState(msg)
|
||||
case StateSelectPrivesc:
|
||||
return m.updateSelectPrivescState(msg)
|
||||
case StateAuthMethodChoice:
|
||||
return m.updateAuthMethodChoiceState(msg)
|
||||
case StateFingerprintAuth:
|
||||
@@ -189,6 +195,8 @@ func (m Model) View() string {
|
||||
return m.viewGentooUseFlags()
|
||||
case StateGentooGCCCheck:
|
||||
return m.viewGentooGCCCheck()
|
||||
case StateSelectPrivesc:
|
||||
return m.viewSelectPrivesc()
|
||||
case StateAuthMethodChoice:
|
||||
return m.viewAuthMethodChoice()
|
||||
case StateFingerprintAuth:
|
||||
|
||||
@@ -10,6 +10,7 @@ const (
|
||||
StateDependencyReview
|
||||
StateGentooUseFlags
|
||||
StateGentooGCCCheck
|
||||
StateSelectPrivesc
|
||||
StateAuthMethodChoice
|
||||
StateFingerprintAuth
|
||||
StatePasswordPrompt
|
||||
|
||||
@@ -180,16 +180,7 @@ func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
// Check if fingerprint is enabled
|
||||
if checkFingerprintEnabled() {
|
||||
m.state = StateAuthMethodChoice
|
||||
m.selectedConfig = 0 // Default to fingerprint
|
||||
return m, nil
|
||||
} else {
|
||||
m.state = StatePasswordPrompt
|
||||
m.passwordInput.Focus()
|
||||
return m, nil
|
||||
}
|
||||
return m.enterAuthPhase()
|
||||
case "esc":
|
||||
m.state = StateSelectWindowManager
|
||||
return m, nil
|
||||
|
||||
@@ -56,14 +56,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.state = StateGentooGCCCheck
|
||||
return m, nil
|
||||
}
|
||||
if checkFingerprintEnabled() {
|
||||
m.state = StateAuthMethodChoice
|
||||
m.selectedConfig = 0
|
||||
} else {
|
||||
m.state = StatePasswordPrompt
|
||||
m.passwordInput.Focus()
|
||||
}
|
||||
return m, nil
|
||||
return m.enterAuthPhase()
|
||||
}
|
||||
|
||||
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 {
|
||||
return m, m.checkGCCVersion()
|
||||
}
|
||||
if checkFingerprintEnabled() {
|
||||
m.state = StateAuthMethodChoice
|
||||
m.selectedConfig = 0
|
||||
} else {
|
||||
m.state = StatePasswordPrompt
|
||||
m.passwordInput.Focus()
|
||||
}
|
||||
return m, nil
|
||||
return m.enterAuthPhase()
|
||||
case "esc":
|
||||
m.state = StateDependencyReview
|
||||
return m, nil
|
||||
|
||||
@@ -139,7 +139,7 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string {
|
||||
if isGit {
|
||||
return "dms-shell-git"
|
||||
}
|
||||
return "dms-shell-bin"
|
||||
return "dms-shell"
|
||||
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE:
|
||||
if isGit {
|
||||
return "dms-git"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
@@ -274,8 +275,7 @@ func (m Model) delayThenReturn() tea.Cmd {
|
||||
|
||||
func (m Model) tryFingerprint() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
clearCmd := exec.Command("sudo", "-k")
|
||||
clearCmd.Run()
|
||||
_ = privesc.ClearCache(context.Background())
|
||||
|
||||
tmpDir := os.TempDir()
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sudo", "-A", "-v")
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript))
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
if err != nil {
|
||||
if err := privesc.ValidateWithAskpass(ctx, askpassScript); err != nil {
|
||||
return passwordValidMsg{password: "", valid: false}
|
||||
}
|
||||
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
if err := privesc.ValidatePassword(ctx, password); err != nil {
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
133
core/internal/tui/views_privesc.go
Normal file
133
core/internal/tui/views_privesc.go
Normal 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
|
||||
}
|
||||
16
docs/IPC.md
16
docs/IPC.md
@@ -502,6 +502,9 @@ Notepad/scratchpad modal control for quick note-taking.
|
||||
- `open` - Show notepad modal
|
||||
- `close` - Hide notepad modal
|
||||
- `toggle` - Toggle notepad modal visibility
|
||||
- `expand` - Expand the active notepad width and open it if hidden
|
||||
- `collapse` - Collapse the active notepad width without changing visibility
|
||||
- `toggleExpand` - Toggle the active notepad width between collapsed and expanded
|
||||
|
||||
### Target: `dash`
|
||||
Dashboard popup control with tab selection for overview, media, and weather information.
|
||||
@@ -610,6 +613,15 @@ dms ipc call powermenu toggle
|
||||
# Open notepad
|
||||
dms ipc call notepad toggle
|
||||
|
||||
# Open the active notepad expanded
|
||||
dms ipc call notepad expand
|
||||
|
||||
# Collapse the active notepad width
|
||||
dms ipc call notepad collapse
|
||||
|
||||
# Toggle the active notepad width
|
||||
dms ipc call notepad toggleExpand
|
||||
|
||||
# Show dashboard with specific tabs
|
||||
dms ipc call dash open overview
|
||||
dms ipc call dash toggle media
|
||||
@@ -647,6 +659,8 @@ binds {
|
||||
Mod+Space { spawn "qs" "-c" "dms" "ipc" "call" "spotlight" "toggle"; }
|
||||
Mod+V { spawn "qs" "-c" "dms" "ipc" "call" "clipboard" "toggle"; }
|
||||
Mod+P { spawn "qs" "-c" "dms" "ipc" "call" "notepad" "toggle"; }
|
||||
Mod+Shift+P { spawn "qs" "-c" "dms" "ipc" "call" "notepad" "expand"; }
|
||||
Mod+Ctrl+P { spawn "qs" "-c" "dms" "ipc" "call" "notepad" "toggleExpand"; }
|
||||
Mod+X { spawn "qs" "-c" "dms" "ipc" "call" "powermenu" "toggle"; }
|
||||
XF86AudioRaiseVolume { spawn "qs" "-c" "dms" "ipc" "call" "audio" "increment" "3"; }
|
||||
XF86MonBrightnessUp { spawn "qs" "-c" "dms" "ipc" "call" "brightness" "increment" "5" ""; }
|
||||
@@ -658,6 +672,8 @@ binds {
|
||||
bind = SUPER, Space, exec, qs -c dms ipc call spotlight toggle
|
||||
bind = SUPER, V, exec, qs -c dms ipc call clipboard toggle
|
||||
bind = SUPER, P, exec, qs -c dms ipc call notepad toggle
|
||||
bind = SUPER SHIFT, P, exec, qs -c dms ipc call notepad expand
|
||||
bind = SUPER CTRL, P, exec, qs -c dms ipc call notepad toggleExpand
|
||||
bind = SUPER, X, exec, qs -c dms ipc call powermenu toggle
|
||||
bind = SUPER, slash, exec, qs -c dms ipc call hypr toggleBinds
|
||||
bind = SUPER, Tab, exec, qs -c dms ipc call hypr toggleOverview
|
||||
|
||||
@@ -34,6 +34,9 @@ const DMS_ACTIONS = [
|
||||
{ id: "spawn dms ipc call notepad toggle", label: "Notepad: Toggle" },
|
||||
{ id: "spawn dms ipc call notepad open", label: "Notepad: Open" },
|
||||
{ id: "spawn dms ipc call notepad close", label: "Notepad: Close" },
|
||||
{ id: "spawn dms ipc call notepad expand", label: "Notepad: Expand" },
|
||||
{ id: "spawn dms ipc call notepad collapse", label: "Notepad: Collapse" },
|
||||
{ id: "spawn dms ipc call notepad toggleExpand", label: "Notepad: Toggle Expand" },
|
||||
{ id: "spawn dms ipc call dash toggle \"\"", label: "Dashboard: Toggle" },
|
||||
{ id: "spawn dms ipc call dash open overview", label: "Dashboard: Overview" },
|
||||
{ id: "spawn dms ipc call dash open media", label: "Dashboard: Media" },
|
||||
|
||||
@@ -29,9 +29,33 @@ Singleton {
|
||||
|
||||
property bool isLightMode: false
|
||||
property bool doNotDisturb: false
|
||||
property real doNotDisturbUntil: 0
|
||||
property bool isSwitchingMode: false
|
||||
property bool suppressOSD: true
|
||||
|
||||
Timer {
|
||||
id: dndExpireTimer
|
||||
repeat: false
|
||||
running: false
|
||||
onTriggered: root.setDoNotDisturb(false)
|
||||
}
|
||||
|
||||
function _armDndExpireTimer() {
|
||||
dndExpireTimer.stop();
|
||||
if (!doNotDisturb || doNotDisturbUntil <= 0)
|
||||
return;
|
||||
const remaining = doNotDisturbUntil - Date.now();
|
||||
if (remaining <= 0) {
|
||||
setDoNotDisturb(false);
|
||||
return;
|
||||
}
|
||||
dndExpireTimer.interval = remaining;
|
||||
dndExpireTimer.start();
|
||||
}
|
||||
|
||||
onDoNotDisturbChanged: _armDndExpireTimer()
|
||||
onDoNotDisturbUntilChanged: _armDndExpireTimer()
|
||||
|
||||
Timer {
|
||||
id: osdSuppressTimer
|
||||
interval: 2000
|
||||
@@ -49,6 +73,7 @@ Singleton {
|
||||
function onSessionResumed() {
|
||||
root.suppressOSD = true;
|
||||
osdSuppressTimer.restart();
|
||||
root._applyDndExpirySanity();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +149,8 @@ Singleton {
|
||||
|
||||
property string vpnLastConnected: ""
|
||||
|
||||
property string lastPlayerIdentity: ""
|
||||
|
||||
property var deviceMaxVolumes: ({})
|
||||
property var hiddenOutputDeviceNames: []
|
||||
property var hiddenInputDeviceNames: []
|
||||
@@ -188,6 +215,7 @@ Singleton {
|
||||
}
|
||||
|
||||
Store.parse(root, obj);
|
||||
_applyDndExpirySanity();
|
||||
|
||||
_loadedSessionSnapshot = getCurrentSessionJson();
|
||||
_hasLoaded = true;
|
||||
@@ -269,6 +297,7 @@ Singleton {
|
||||
}
|
||||
|
||||
Store.parse(root, obj);
|
||||
_applyDndExpirySanity();
|
||||
|
||||
_loadedSessionSnapshot = getCurrentSessionJson();
|
||||
_hasLoaded = true;
|
||||
@@ -286,6 +315,16 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function _applyDndExpirySanity() {
|
||||
if (doNotDisturb && doNotDisturbUntil > 0 && Date.now() >= doNotDisturbUntil) {
|
||||
doNotDisturb = false;
|
||||
doNotDisturbUntil = 0;
|
||||
} else if (!doNotDisturb && doNotDisturbUntil !== 0) {
|
||||
doNotDisturbUntil = 0;
|
||||
}
|
||||
_armDndExpireTimer();
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
if (isGreeterMode || _parseError || !_hasLoaded)
|
||||
return;
|
||||
@@ -355,8 +394,21 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
function setDoNotDisturb(enabled) {
|
||||
function setDoNotDisturb(enabled, durationMinutes) {
|
||||
const minutes = Number(durationMinutes) || 0;
|
||||
doNotDisturb = enabled;
|
||||
doNotDisturbUntil = (enabled && minutes > 0) ? Date.now() + minutes * 60 * 1000 : 0;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setDoNotDisturbUntilTimestamp(timestampMs) {
|
||||
const target = Number(timestampMs) || 0;
|
||||
if (target <= Date.now()) {
|
||||
setDoNotDisturb(false);
|
||||
return;
|
||||
}
|
||||
doNotDisturb = true;
|
||||
doNotDisturbUntil = target;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@ Singleton {
|
||||
property var workspaceNameIcons: ({})
|
||||
property bool waveProgressEnabled: true
|
||||
property bool scrollTitleEnabled: true
|
||||
property bool mediaAdaptiveWidthEnabled: true
|
||||
property bool audioVisualizerEnabled: true
|
||||
property string audioScrollMode: "volume"
|
||||
property int audioWheelScrollAmount: 5
|
||||
@@ -358,6 +359,8 @@ Singleton {
|
||||
property string dankLauncherV2BorderColor: "primary"
|
||||
property bool dankLauncherV2ShowFooter: true
|
||||
property bool dankLauncherV2UnloadOnClose: false
|
||||
property bool dankLauncherV2IncludeFilesInAll: false
|
||||
property bool dankLauncherV2IncludeFoldersInAll: false
|
||||
|
||||
property string _legacyWeatherLocation: "New York, NY"
|
||||
property string _legacyWeatherCoordinates: "40.7128,-74.0060"
|
||||
@@ -434,17 +437,20 @@ Singleton {
|
||||
property bool soundNewNotification: true
|
||||
property bool soundVolumeChanged: true
|
||||
property bool soundPluggedIn: true
|
||||
property bool soundLogin: false
|
||||
|
||||
property int acMonitorTimeout: 0
|
||||
property int acLockTimeout: 0
|
||||
property int acSuspendTimeout: 0
|
||||
property int acSuspendBehavior: SettingsData.SuspendBehavior.Suspend
|
||||
property string acProfileName: ""
|
||||
property int acPostLockMonitorTimeout: 0
|
||||
property int batteryMonitorTimeout: 0
|
||||
property int batteryLockTimeout: 0
|
||||
property int batterySuspendTimeout: 0
|
||||
property int batterySuspendBehavior: SettingsData.SuspendBehavior.Suspend
|
||||
property string batteryProfileName: ""
|
||||
property int batteryPostLockMonitorTimeout: 0
|
||||
property int batteryChargeLimit: 100
|
||||
property bool lockBeforeSuspend: false
|
||||
property bool loginctlLockIntegration: true
|
||||
@@ -554,24 +560,24 @@ Singleton {
|
||||
|
||||
property bool enableFprint: false
|
||||
property int maxFprintTries: 15
|
||||
property bool fprintdAvailable: false
|
||||
property bool lockFingerprintCanEnable: false
|
||||
property bool lockFingerprintReady: false
|
||||
property string lockFingerprintReason: "probe_failed"
|
||||
property bool greeterFingerprintCanEnable: false
|
||||
property bool greeterFingerprintReady: false
|
||||
property string greeterFingerprintReason: "probe_failed"
|
||||
property string greeterFingerprintSource: "none"
|
||||
readonly property bool fprintdAvailable: Processes.fprintdAvailable
|
||||
readonly property bool lockFingerprintCanEnable: Processes.lockFingerprintCanEnable
|
||||
readonly property bool lockFingerprintReady: Processes.lockFingerprintReady
|
||||
readonly property string lockFingerprintReason: Processes.lockFingerprintReason
|
||||
readonly property bool greeterFingerprintCanEnable: Processes.greeterFingerprintCanEnable
|
||||
readonly property bool greeterFingerprintReady: Processes.greeterFingerprintReady
|
||||
readonly property string greeterFingerprintReason: Processes.greeterFingerprintReason
|
||||
readonly property string greeterFingerprintSource: Processes.greeterFingerprintSource
|
||||
property bool enableU2f: false
|
||||
property string u2fMode: "or"
|
||||
property bool u2fAvailable: false
|
||||
property bool lockU2fCanEnable: false
|
||||
property bool lockU2fReady: false
|
||||
property string lockU2fReason: "probe_failed"
|
||||
property bool greeterU2fCanEnable: false
|
||||
property bool greeterU2fReady: false
|
||||
property string greeterU2fReason: "probe_failed"
|
||||
property string greeterU2fSource: "none"
|
||||
readonly property bool u2fAvailable: Processes.u2fAvailable
|
||||
readonly property bool lockU2fCanEnable: Processes.lockU2fCanEnable
|
||||
readonly property bool lockU2fReady: Processes.lockU2fReady
|
||||
readonly property string lockU2fReason: Processes.lockU2fReason
|
||||
readonly property bool greeterU2fCanEnable: Processes.greeterU2fCanEnable
|
||||
readonly property bool greeterU2fReady: Processes.greeterU2fReady
|
||||
readonly property string greeterU2fReason: Processes.greeterU2fReason
|
||||
readonly property string greeterU2fSource: Processes.greeterU2fSource
|
||||
property string lockScreenActiveMonitor: "all"
|
||||
property string lockScreenInactiveColor: "#000000"
|
||||
property int lockScreenNotificationMode: 0
|
||||
@@ -1061,7 +1067,6 @@ Singleton {
|
||||
function refreshAuthAvailability() {
|
||||
if (isGreeterMode)
|
||||
return;
|
||||
Processes.settingsRoot = root;
|
||||
Processes.detectAuthCapabilities();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,10 @@ Singleton {
|
||||
|
||||
property string fingerprintProbeOutput: ""
|
||||
property int fingerprintProbeExitCode: 0
|
||||
property bool fingerprintProbeStreamFinished: false
|
||||
property bool fingerprintProbeExited: false
|
||||
property string fingerprintProbeState: "probe_failed"
|
||||
property bool fingerprintProbeFinalized: false
|
||||
|
||||
property string pamSupportProbeOutput: ""
|
||||
property bool pamSupportProbeStreamFinished: false
|
||||
property bool pamSupportProbeExited: false
|
||||
property int pamSupportProbeExitCode: 0
|
||||
property bool pamFprintSupportDetected: false
|
||||
property bool pamU2fSupportDetected: false
|
||||
property string pamProbeOutput: ""
|
||||
property bool pamProbeFinalized: false
|
||||
|
||||
readonly property string homeDir: Quickshell.env("HOME") || ""
|
||||
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
|
||||
@@ -54,40 +48,189 @@ Singleton {
|
||||
|
||||
readonly property var forcedFprintAvailable: envFlag("DMS_FORCE_FPRINT_AVAILABLE")
|
||||
readonly property var forcedU2fAvailable: envFlag("DMS_FORCE_U2F_AVAILABLE")
|
||||
property bool authApplyRunning: false
|
||||
property bool authApplyQueued: false
|
||||
property bool authApplyRerunRequested: false
|
||||
property bool authApplyTerminalFallbackFromPrecheck: false
|
||||
property string authApplyStdout: ""
|
||||
property string authApplyStderr: ""
|
||||
property string authApplySudoProbeStderr: ""
|
||||
property string authApplyTerminalFallbackStderr: ""
|
||||
|
||||
function detectQtTools() {
|
||||
qtToolsDetectionProcess.running = true;
|
||||
// --- Derived auth probe state ---
|
||||
|
||||
readonly property bool pamFprintSupportDetected: pamProbeFinalized && pamProbeOutput.includes("pam_fprintd.so:true")
|
||||
readonly property bool pamU2fSupportDetected: pamProbeFinalized && pamProbeOutput.includes("pam_u2f.so:true")
|
||||
|
||||
readonly property string fingerprintProbeState: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable ? "ready" : "probe_failed";
|
||||
if (!fingerprintProbeFinalized)
|
||||
return "probe_failed";
|
||||
return parseFingerprintProbe(fingerprintProbeExitCode, fingerprintProbeOutput, pamFprintSupportDetected);
|
||||
}
|
||||
|
||||
function detectAuthCapabilities() {
|
||||
if (!settingsRoot)
|
||||
return;
|
||||
// --- Lock fingerprint capabilities ---
|
||||
|
||||
readonly property bool lockFingerprintCanEnable: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable;
|
||||
switch (fingerprintProbeState) {
|
||||
case "ready":
|
||||
case "missing_enrollment":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property bool lockFingerprintReady: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable;
|
||||
return fingerprintProbeState === "ready";
|
||||
}
|
||||
|
||||
readonly property string lockFingerprintReason: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable ? "ready" : "probe_failed";
|
||||
return fingerprintProbeState;
|
||||
}
|
||||
|
||||
// --- Greeter fingerprint capabilities ---
|
||||
|
||||
readonly property bool greeterFingerprintCanEnable: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable;
|
||||
if (greeterPamHasFprint)
|
||||
return fingerprintProbeState !== "missing_reader";
|
||||
switch (fingerprintProbeState) {
|
||||
case "ready":
|
||||
case "missing_enrollment":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property bool greeterFingerprintReady: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable;
|
||||
return fingerprintProbeState === "ready";
|
||||
}
|
||||
|
||||
readonly property string greeterFingerprintReason: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable ? "ready" : "probe_failed";
|
||||
if (greeterPamHasFprint) {
|
||||
switch (fingerprintProbeState) {
|
||||
case "ready":
|
||||
return "configured_externally";
|
||||
case "missing_enrollment":
|
||||
return "missing_enrollment";
|
||||
case "missing_reader":
|
||||
return "missing_reader";
|
||||
default:
|
||||
return "probe_failed";
|
||||
}
|
||||
}
|
||||
return fingerprintProbeState;
|
||||
}
|
||||
|
||||
readonly property string greeterFingerprintSource: {
|
||||
if (forcedFprintAvailable !== null)
|
||||
return forcedFprintAvailable ? "dms" : "none";
|
||||
if (greeterPamHasFprint)
|
||||
return "pam";
|
||||
switch (fingerprintProbeState) {
|
||||
case "ready":
|
||||
case "missing_enrollment":
|
||||
return "dms";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lock U2F capabilities ---
|
||||
|
||||
readonly property bool lockU2fReady: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable;
|
||||
return lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
||||
}
|
||||
|
||||
readonly property bool lockU2fCanEnable: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable;
|
||||
return lockU2fReady || pamU2fSupportDetected;
|
||||
}
|
||||
|
||||
readonly property string lockU2fReason: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable ? "ready" : "probe_failed";
|
||||
if (lockU2fReady)
|
||||
return "ready";
|
||||
if (lockU2fCanEnable)
|
||||
return "missing_key_registration";
|
||||
return "missing_pam_support";
|
||||
}
|
||||
|
||||
// --- Greeter U2F capabilities ---
|
||||
|
||||
readonly property bool greeterU2fReady: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable;
|
||||
if (greeterPamHasU2f)
|
||||
return true;
|
||||
return homeU2fKeysDetected;
|
||||
}
|
||||
|
||||
readonly property bool greeterU2fCanEnable: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable;
|
||||
if (greeterPamHasU2f)
|
||||
return true;
|
||||
return greeterU2fReady || pamU2fSupportDetected;
|
||||
}
|
||||
|
||||
readonly property string greeterU2fReason: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable ? "ready" : "probe_failed";
|
||||
if (greeterPamHasU2f)
|
||||
return "configured_externally";
|
||||
if (greeterU2fReady)
|
||||
return "ready";
|
||||
if (greeterU2fCanEnable)
|
||||
return "missing_key_registration";
|
||||
return "missing_pam_support";
|
||||
}
|
||||
|
||||
readonly property string greeterU2fSource: {
|
||||
if (forcedU2fAvailable !== null)
|
||||
return forcedU2fAvailable ? "dms" : "none";
|
||||
if (greeterPamHasU2f)
|
||||
return "pam";
|
||||
if (greeterU2fCanEnable)
|
||||
return "dms";
|
||||
return "none";
|
||||
}
|
||||
|
||||
// --- Aggregates ---
|
||||
|
||||
readonly property bool fprintdAvailable: lockFingerprintReady || greeterFingerprintReady
|
||||
readonly property bool u2fAvailable: lockU2fReady || greeterU2fReady
|
||||
|
||||
// --- Auth detection ---
|
||||
|
||||
readonly property var _fprintProbeCommand: ["sh", "-c", "if command -v fprintd-list >/dev/null 2>&1; then fprintd-list \"${USER:-$(id -un)}\" 2>&1; else printf '__missing_command__\\n'; exit 127; fi"]
|
||||
readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
|
||||
|
||||
function detectAuthCapabilities() {
|
||||
if (forcedFprintAvailable === null) {
|
||||
fingerprintProbeOutput = "";
|
||||
fingerprintProbeStreamFinished = false;
|
||||
fingerprintProbeExited = false;
|
||||
fingerprintProbeProcess.running = true;
|
||||
} else {
|
||||
fingerprintProbeState = forcedFprintAvailable ? "ready" : "probe_failed";
|
||||
fingerprintProbeFinalized = false;
|
||||
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
|
||||
fingerprintProbeOutput = output || "";
|
||||
fingerprintProbeExitCode = exitCode;
|
||||
fingerprintProbeFinalized = true;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
pamFprintSupportDetected = false;
|
||||
pamU2fSupportDetected = false;
|
||||
pamSupportProbeOutput = "";
|
||||
pamSupportProbeStreamFinished = false;
|
||||
pamSupportProbeExited = false;
|
||||
pamSupportDetectionProcess.running = true;
|
||||
|
||||
recomputeAuthCapabilities();
|
||||
pamProbeFinalized = false;
|
||||
Proc.runCommand("pam-probe", _pamProbeCommand, (output, _exitCode) => {
|
||||
pamProbeOutput = output || "";
|
||||
pamProbeFinalized = true;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function detectFprintd() {
|
||||
@@ -98,9 +241,16 @@ Singleton {
|
||||
detectAuthCapabilities();
|
||||
}
|
||||
|
||||
function checkPluginSettings() {
|
||||
pluginSettingsCheckProcess.running = true;
|
||||
}
|
||||
// --- Auth apply pipeline ---
|
||||
|
||||
property bool authApplyRunning: false
|
||||
property bool authApplyQueued: false
|
||||
property bool authApplyRerunRequested: false
|
||||
property bool authApplyTerminalFallbackFromPrecheck: false
|
||||
property string authApplyStdout: ""
|
||||
property string authApplyStderr: ""
|
||||
property string authApplySudoProbeStderr: ""
|
||||
property string authApplyTerminalFallbackStderr: ""
|
||||
|
||||
function scheduleAuthApply() {
|
||||
if (!settingsRoot || settingsRoot.isGreeterMode)
|
||||
@@ -146,6 +296,8 @@ Singleton {
|
||||
authApplyDebounce.restart();
|
||||
}
|
||||
|
||||
// --- PAM parsing helpers ---
|
||||
|
||||
function stripPamComment(line) {
|
||||
if (!line)
|
||||
return "";
|
||||
@@ -189,15 +341,7 @@ Singleton {
|
||||
function greeterPamStackHasModule(moduleName) {
|
||||
if (pamModuleEnabled(greetdPamText, moduleName))
|
||||
return true;
|
||||
const includedPamStacks = [
|
||||
["system-auth", systemAuthPamText],
|
||||
["common-auth", commonAuthPamText],
|
||||
["password-auth", passwordAuthPamText],
|
||||
["system-login", systemLoginPamText],
|
||||
["system-local-login", systemLocalLoginPamText],
|
||||
["common-auth-pc", commonAuthPcPamText],
|
||||
["login", loginPamText]
|
||||
];
|
||||
const includedPamStacks = [["system-auth", systemAuthPamText], ["common-auth", commonAuthPamText], ["password-auth", passwordAuthPamText], ["system-login", systemLoginPamText], ["system-local-login", systemLocalLoginPamText], ["common-auth-pc", commonAuthPcPamText], ["login", loginPamText]];
|
||||
for (let i = 0; i < includedPamStacks.length; i++) {
|
||||
const stack = includedPamStacks[i];
|
||||
if (pamTextIncludesFile(greetdPamText, stack[0]) && pamModuleEnabled(stack[1], moduleName))
|
||||
@@ -206,6 +350,8 @@ Singleton {
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Fingerprint probe output parsing ---
|
||||
|
||||
function hasEnrolledFingerprintOutput(output) {
|
||||
const lower = (output || "").toLowerCase();
|
||||
if (lower.includes("has fingers enrolled") || lower.includes("has fingerprints enrolled"))
|
||||
@@ -223,21 +369,15 @@ Singleton {
|
||||
|
||||
function hasMissingFingerprintEnrollmentOutput(output) {
|
||||
const lower = (output || "").toLowerCase();
|
||||
return lower.includes("no fingers enrolled")
|
||||
|| lower.includes("no fingerprints enrolled")
|
||||
|| lower.includes("no prints enrolled");
|
||||
return lower.includes("no fingers enrolled") || lower.includes("no fingerprints enrolled") || lower.includes("no prints enrolled");
|
||||
}
|
||||
|
||||
function hasMissingFingerprintReaderOutput(output) {
|
||||
const lower = (output || "").toLowerCase();
|
||||
return lower.includes("no devices available")
|
||||
|| lower.includes("no device available")
|
||||
|| lower.includes("no devices found")
|
||||
|| lower.includes("list_devices failed")
|
||||
|| lower.includes("no device");
|
||||
return lower.includes("no devices available") || lower.includes("no device available") || lower.includes("no devices found") || lower.includes("list_devices failed") || lower.includes("no device");
|
||||
}
|
||||
|
||||
function parseFingerprintProbe(exitCode, output) {
|
||||
function parseFingerprintProbe(exitCode, output, pamFprintDetected) {
|
||||
if (hasEnrolledFingerprintOutput(output))
|
||||
return "ready";
|
||||
if (hasMissingFingerprintEnrollmentOutput(output))
|
||||
@@ -248,164 +388,17 @@ Singleton {
|
||||
return "missing_enrollment";
|
||||
if (exitCode === 127 || (output || "").includes("__missing_command__"))
|
||||
return "probe_failed";
|
||||
return pamFprintSupportDetected ? "probe_failed" : "missing_pam_support";
|
||||
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
|
||||
}
|
||||
|
||||
function setLockFingerprintCapability(canEnable, ready, reason) {
|
||||
settingsRoot.lockFingerprintCanEnable = canEnable;
|
||||
settingsRoot.lockFingerprintReady = ready;
|
||||
settingsRoot.lockFingerprintReason = reason;
|
||||
// --- Qt tools detection ---
|
||||
|
||||
function detectQtTools() {
|
||||
qtToolsDetectionProcess.running = true;
|
||||
}
|
||||
|
||||
function setLockU2fCapability(canEnable, ready, reason) {
|
||||
settingsRoot.lockU2fCanEnable = canEnable;
|
||||
settingsRoot.lockU2fReady = ready;
|
||||
settingsRoot.lockU2fReason = reason;
|
||||
}
|
||||
|
||||
function setGreeterFingerprintCapability(canEnable, ready, reason, source) {
|
||||
settingsRoot.greeterFingerprintCanEnable = canEnable;
|
||||
settingsRoot.greeterFingerprintReady = ready;
|
||||
settingsRoot.greeterFingerprintReason = reason;
|
||||
settingsRoot.greeterFingerprintSource = source;
|
||||
}
|
||||
|
||||
function setGreeterU2fCapability(canEnable, ready, reason, source) {
|
||||
settingsRoot.greeterU2fCanEnable = canEnable;
|
||||
settingsRoot.greeterU2fReady = ready;
|
||||
settingsRoot.greeterU2fReason = reason;
|
||||
settingsRoot.greeterU2fSource = source;
|
||||
}
|
||||
|
||||
function recomputeFingerprintCapabilities() {
|
||||
if (forcedFprintAvailable !== null) {
|
||||
const reason = forcedFprintAvailable ? "ready" : "probe_failed";
|
||||
const source = forcedFprintAvailable ? "dms" : "none";
|
||||
setLockFingerprintCapability(forcedFprintAvailable, forcedFprintAvailable, reason);
|
||||
setGreeterFingerprintCapability(forcedFprintAvailable, forcedFprintAvailable, reason, source);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = fingerprintProbeState;
|
||||
|
||||
switch (state) {
|
||||
case "ready":
|
||||
setLockFingerprintCapability(true, true, "ready");
|
||||
break;
|
||||
case "missing_enrollment":
|
||||
setLockFingerprintCapability(true, false, "missing_enrollment");
|
||||
break;
|
||||
case "missing_reader":
|
||||
setLockFingerprintCapability(false, false, "missing_reader");
|
||||
break;
|
||||
case "missing_pam_support":
|
||||
setLockFingerprintCapability(false, false, "missing_pam_support");
|
||||
break;
|
||||
default:
|
||||
setLockFingerprintCapability(false, false, "probe_failed");
|
||||
break;
|
||||
}
|
||||
|
||||
if (greeterPamHasFprint) {
|
||||
switch (state) {
|
||||
case "ready":
|
||||
setGreeterFingerprintCapability(true, true, "configured_externally", "pam");
|
||||
break;
|
||||
case "missing_enrollment":
|
||||
setGreeterFingerprintCapability(true, false, "missing_enrollment", "pam");
|
||||
break;
|
||||
case "missing_reader":
|
||||
setGreeterFingerprintCapability(false, false, "missing_reader", "pam");
|
||||
break;
|
||||
default:
|
||||
setGreeterFingerprintCapability(true, false, "probe_failed", "pam");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case "ready":
|
||||
setGreeterFingerprintCapability(true, true, "ready", "dms");
|
||||
break;
|
||||
case "missing_enrollment":
|
||||
setGreeterFingerprintCapability(true, false, "missing_enrollment", "dms");
|
||||
break;
|
||||
case "missing_reader":
|
||||
setGreeterFingerprintCapability(false, false, "missing_reader", "none");
|
||||
break;
|
||||
case "missing_pam_support":
|
||||
setGreeterFingerprintCapability(false, false, "missing_pam_support", "none");
|
||||
break;
|
||||
default:
|
||||
setGreeterFingerprintCapability(false, false, "probe_failed", "none");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function recomputeU2fCapabilities() {
|
||||
if (forcedU2fAvailable !== null) {
|
||||
const reason = forcedU2fAvailable ? "ready" : "probe_failed";
|
||||
const source = forcedU2fAvailable ? "dms" : "none";
|
||||
setLockU2fCapability(forcedU2fAvailable, forcedU2fAvailable, reason);
|
||||
setGreeterU2fCapability(forcedU2fAvailable, forcedU2fAvailable, reason, source);
|
||||
return;
|
||||
}
|
||||
|
||||
const lockReady = lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
||||
const lockCanEnable = lockReady || pamU2fSupportDetected;
|
||||
const lockReason = lockReady ? "ready" : (lockCanEnable ? "missing_key_registration" : "missing_pam_support");
|
||||
setLockU2fCapability(lockCanEnable, lockReady, lockReason);
|
||||
|
||||
if (greeterPamHasU2f) {
|
||||
setGreeterU2fCapability(true, true, "configured_externally", "pam");
|
||||
return;
|
||||
}
|
||||
|
||||
const greeterReady = homeU2fKeysDetected;
|
||||
const greeterCanEnable = greeterReady || pamU2fSupportDetected;
|
||||
const greeterReason = greeterReady ? "ready" : (greeterCanEnable ? "missing_key_registration" : "missing_pam_support");
|
||||
setGreeterU2fCapability(greeterCanEnable, greeterReady, greeterReason, greeterCanEnable ? "dms" : "none");
|
||||
}
|
||||
|
||||
function recomputeAuthCapabilities() {
|
||||
if (!settingsRoot)
|
||||
return;
|
||||
recomputeFingerprintCapabilities();
|
||||
recomputeU2fCapabilities();
|
||||
settingsRoot.fprintdAvailable = settingsRoot.lockFingerprintReady || settingsRoot.greeterFingerprintReady;
|
||||
settingsRoot.u2fAvailable = settingsRoot.lockU2fReady || settingsRoot.greeterU2fReady;
|
||||
}
|
||||
|
||||
function finalizeFingerprintProbe() {
|
||||
if (!fingerprintProbeStreamFinished || !fingerprintProbeExited)
|
||||
return;
|
||||
fingerprintProbeState = parseFingerprintProbe(fingerprintProbeExitCode, fingerprintProbeOutput);
|
||||
recomputeAuthCapabilities();
|
||||
}
|
||||
|
||||
function finalizePamSupportProbe() {
|
||||
if (!pamSupportProbeStreamFinished || !pamSupportProbeExited)
|
||||
return;
|
||||
|
||||
pamFprintSupportDetected = false;
|
||||
pamU2fSupportDetected = false;
|
||||
|
||||
const lines = (pamSupportProbeOutput || "").trim().split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
if (parts.length !== 2)
|
||||
continue;
|
||||
if (parts[0] === "pam_fprintd.so")
|
||||
pamFprintSupportDetected = parts[1] === "true";
|
||||
else if (parts[0] === "pam_u2f.so")
|
||||
pamU2fSupportDetected = parts[1] === "true";
|
||||
}
|
||||
|
||||
if (forcedFprintAvailable === null && fingerprintProbeState === "missing_pam_support")
|
||||
fingerprintProbeState = parseFingerprintProbe(fingerprintProbeExitCode, fingerprintProbeOutput);
|
||||
|
||||
recomputeAuthCapabilities();
|
||||
function checkPluginSettings() {
|
||||
pluginSettingsCheckProcess.running = true;
|
||||
}
|
||||
|
||||
property var qtToolsDetectionProcess: Process {
|
||||
@@ -433,44 +426,6 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
property var fingerprintProbeProcess: Process {
|
||||
command: ["sh", "-c", "if command -v fprintd-list >/dev/null 2>&1; then fprintd-list \"${USER:-$(id -un)}\" 2>&1; else printf '__missing_command__\\n'; exit 127; fi"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.fingerprintProbeOutput = text || "";
|
||||
root.fingerprintProbeStreamFinished = true;
|
||||
root.finalizeFingerprintProbe();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
root.fingerprintProbeExitCode = exitCode;
|
||||
root.fingerprintProbeExited = true;
|
||||
root.finalizeFingerprintProbe();
|
||||
}
|
||||
}
|
||||
|
||||
property var pamSupportDetectionProcess: Process {
|
||||
command: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.pamSupportProbeOutput = text || "";
|
||||
root.pamSupportProbeStreamFinished = true;
|
||||
root.finalizePamSupportProbe();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
root.pamSupportProbeExitCode = exitCode;
|
||||
root.pamSupportProbeExited = true;
|
||||
root.finalizePamSupportProbe();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: authApplyDebounce
|
||||
interval: 300
|
||||
@@ -544,9 +499,7 @@ Singleton {
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode === 0) {
|
||||
const message = root.authApplyTerminalFallbackFromPrecheck
|
||||
? I18n.tr("Terminal opened. Complete authentication setup there; it will close automatically when done.")
|
||||
: I18n.tr("Terminal fallback opened. Complete authentication setup there; it will close automatically when done.");
|
||||
const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication setup there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication setup there; it will close automatically when done.");
|
||||
ToastService.showInfo(message, "", "", "auth-sync");
|
||||
} else {
|
||||
let details = (root.authApplyTerminalFallbackStderr || "").trim();
|
||||
@@ -560,140 +513,80 @@ Singleton {
|
||||
id: greetdPamWatcher
|
||||
path: "/etc/pam.d/greetd"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.greetdPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.greetdPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.greetdPamText = text()
|
||||
onLoadFailed: root.greetdPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: systemAuthPamWatcher
|
||||
path: "/etc/pam.d/system-auth"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.systemAuthPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.systemAuthPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.systemAuthPamText = text()
|
||||
onLoadFailed: root.systemAuthPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: commonAuthPamWatcher
|
||||
path: "/etc/pam.d/common-auth"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.commonAuthPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.commonAuthPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.commonAuthPamText = text()
|
||||
onLoadFailed: root.commonAuthPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: passwordAuthPamWatcher
|
||||
path: "/etc/pam.d/password-auth"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.passwordAuthPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.passwordAuthPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.passwordAuthPamText = text()
|
||||
onLoadFailed: root.passwordAuthPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: systemLoginPamWatcher
|
||||
path: "/etc/pam.d/system-login"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.systemLoginPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.systemLoginPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.systemLoginPamText = text()
|
||||
onLoadFailed: root.systemLoginPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: systemLocalLoginPamWatcher
|
||||
path: "/etc/pam.d/system-local-login"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.systemLocalLoginPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.systemLocalLoginPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.systemLocalLoginPamText = text()
|
||||
onLoadFailed: root.systemLocalLoginPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: commonAuthPcPamWatcher
|
||||
path: "/etc/pam.d/common-auth-pc"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.commonAuthPcPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.commonAuthPcPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.commonAuthPcPamText = text()
|
||||
onLoadFailed: root.commonAuthPcPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: loginPamWatcher
|
||||
path: "/etc/pam.d/login"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.loginPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.loginPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.loginPamText = text()
|
||||
onLoadFailed: root.loginPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: dankshellU2fPamWatcher
|
||||
path: "/etc/pam.d/dankshell-u2f"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.dankshellU2fPamText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.dankshellU2fPamText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.dankshellU2fPamText = text()
|
||||
onLoadFailed: root.dankshellU2fPamText = ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: u2fKeysWatcher
|
||||
path: root.u2fKeysPath
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
root.u2fKeysText = text();
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoadFailed: {
|
||||
root.u2fKeysText = "";
|
||||
root.recomputeAuthCapabilities();
|
||||
}
|
||||
onLoaded: root.u2fKeysText = text()
|
||||
onLoadFailed: root.u2fKeysText = ""
|
||||
}
|
||||
|
||||
property var pluginSettingsCheckProcess: Process {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
var SPEC = {
|
||||
isLightMode: { def: false },
|
||||
doNotDisturb: { def: false },
|
||||
doNotDisturbUntil: { def: 0 },
|
||||
|
||||
wallpaperPath: { def: "" },
|
||||
perMonitorWallpaper: { def: false },
|
||||
@@ -75,6 +76,8 @@ var SPEC = {
|
||||
|
||||
vpnLastConnected: { def: "" },
|
||||
|
||||
lastPlayerIdentity: { def: "" },
|
||||
|
||||
deviceMaxVolumes: { def: {} },
|
||||
hiddenOutputDeviceNames: { def: [] },
|
||||
hiddenInputDeviceNames: { def: [] },
|
||||
|
||||
@@ -140,6 +140,7 @@ var SPEC = {
|
||||
workspaceNameIcons: { def: {} },
|
||||
waveProgressEnabled: { def: true },
|
||||
scrollTitleEnabled: { def: true },
|
||||
mediaAdaptiveWidthEnabled: { def: true },
|
||||
audioVisualizerEnabled: { def: true },
|
||||
audioScrollMode: { def: "volume" },
|
||||
audioWheelScrollAmount: { def: 5 },
|
||||
@@ -203,6 +204,8 @@ var SPEC = {
|
||||
dankLauncherV2BorderColor: { def: "primary" },
|
||||
dankLauncherV2ShowFooter: { def: true },
|
||||
dankLauncherV2UnloadOnClose: { def: false },
|
||||
dankLauncherV2IncludeFilesInAll: { def: false },
|
||||
dankLauncherV2IncludeFoldersInAll: { def: false },
|
||||
|
||||
useAutoLocation: { def: false },
|
||||
weatherEnabled: { def: true },
|
||||
@@ -242,6 +245,7 @@ var SPEC = {
|
||||
|
||||
soundsEnabled: { def: true },
|
||||
useSystemSoundTheme: { def: false },
|
||||
soundLogin: { def: false },
|
||||
soundNewNotification: { def: true },
|
||||
soundVolumeChanged: { def: true },
|
||||
soundPluggedIn: { def: true },
|
||||
@@ -251,11 +255,13 @@ var SPEC = {
|
||||
acSuspendTimeout: { def: 0 },
|
||||
acSuspendBehavior: { def: 0 },
|
||||
acProfileName: { def: "" },
|
||||
acPostLockMonitorTimeout: { def: 0 },
|
||||
batteryMonitorTimeout: { def: 0 },
|
||||
batteryLockTimeout: { def: 0 },
|
||||
batterySuspendTimeout: { def: 0 },
|
||||
batterySuspendBehavior: { def: 0 },
|
||||
batteryProfileName: { def: "" },
|
||||
batteryPostLockMonitorTimeout: { def: 0 },
|
||||
batteryChargeLimit: { def: 100 },
|
||||
lockBeforeSuspend: { def: false },
|
||||
loginctlLockIntegration: { def: true },
|
||||
@@ -358,26 +364,10 @@ var SPEC = {
|
||||
lockScreenShowMediaPlayer: { def: true },
|
||||
lockScreenPowerOffMonitorsOnLock: { def: false },
|
||||
lockAtStartup: { def: false },
|
||||
enableFprint: { def: false, onChange: "scheduleAuthApply" },
|
||||
enableFprint: { def: false },
|
||||
maxFprintTries: { def: 15 },
|
||||
fprintdAvailable: { def: false, persist: false },
|
||||
lockFingerprintCanEnable: { def: false, persist: false },
|
||||
lockFingerprintReady: { def: false, persist: false },
|
||||
lockFingerprintReason: { def: "probe_failed", persist: false },
|
||||
greeterFingerprintCanEnable: { def: false, persist: false },
|
||||
greeterFingerprintReady: { def: false, persist: false },
|
||||
greeterFingerprintReason: { def: "probe_failed", persist: false },
|
||||
greeterFingerprintSource: { def: "none", persist: false },
|
||||
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||
u2fMode: { def: "or" },
|
||||
u2fAvailable: { def: false, persist: false },
|
||||
lockU2fCanEnable: { def: false, persist: false },
|
||||
lockU2fReady: { def: false, persist: false },
|
||||
lockU2fReason: { def: "probe_failed", persist: false },
|
||||
greeterU2fCanEnable: { def: false, persist: false },
|
||||
greeterU2fReady: { def: false, persist: false },
|
||||
greeterU2fReason: { def: "probe_failed", persist: false },
|
||||
greeterU2fSource: { def: "none", persist: false },
|
||||
lockScreenActiveMonitor: { def: "all" },
|
||||
lockScreenInactiveColor: { def: "#000000" },
|
||||
lockScreenNotificationMode: { def: 0 },
|
||||
|
||||
@@ -221,10 +221,22 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: loginSoundTimer
|
||||
// Half a second delay before playing login sound, otherwise the sound may be cut off
|
||||
// 50 is the minimum that seems to work, but 500 is safer
|
||||
interval: 500
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
AudioService.playLoginSoundIfApplicable();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
dockRecreateDebounce.start();
|
||||
// Force PolkitService singleton to initialize
|
||||
PolkitService.polkitAvailable;
|
||||
loginSoundTimer.start();
|
||||
}
|
||||
|
||||
Loader {
|
||||
|
||||
@@ -310,6 +310,37 @@ Item {
|
||||
return "NOTEPAD_TOGGLE_FAILED";
|
||||
}
|
||||
|
||||
function expand(): string {
|
||||
var instance = getActiveNotepadInstance();
|
||||
if (instance) {
|
||||
instance.expandedWidth = true;
|
||||
if (!instance.isVisible)
|
||||
instance.show();
|
||||
return "NOTEPAD_EXPAND_SUCCESS";
|
||||
}
|
||||
return "NOTEPAD_EXPAND_FAILED";
|
||||
}
|
||||
|
||||
function collapse(): string {
|
||||
var instance = getActiveNotepadInstance();
|
||||
if (instance) {
|
||||
instance.expandedWidth = false;
|
||||
if (!instance.isVisible)
|
||||
instance.show();
|
||||
return "NOTEPAD_COLLAPSE_SUCCESS";
|
||||
}
|
||||
return "NOTEPAD_COLLAPSE_FAILED";
|
||||
}
|
||||
|
||||
function toggleExpand(): string {
|
||||
var instance = getActiveNotepadInstance();
|
||||
if (instance) {
|
||||
instance.expandedWidth = !instance.expandedWidth;
|
||||
return "NOTEPAD_TOGGLE_EXPAND_SUCCESS";
|
||||
}
|
||||
return "NOTEPAD_TOGGLE_EXPAND_FAILED";
|
||||
}
|
||||
|
||||
target: "notepad"
|
||||
}
|
||||
|
||||
@@ -369,9 +400,7 @@ Item {
|
||||
}
|
||||
|
||||
function previous(): void {
|
||||
if (MprisController.activePlayer && MprisController.activePlayer.canGoPrevious) {
|
||||
MprisController.activePlayer.previous();
|
||||
}
|
||||
MprisController.previousOrRewind();
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
|
||||
@@ -122,7 +122,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("No recent clipboard entries found")
|
||||
text: clipboardContent.modal.clipboardAvailable ? I18n.tr("No recent clipboard entries found") : I18n.tr("Connecting to clipboard service…")
|
||||
anchors.centerIn: parent
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
@@ -181,7 +181,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("No saved clipboard entries")
|
||||
text: clipboardContent.modal.clipboardAvailable ? I18n.tr("No saved clipboard entries") : I18n.tr("Connecting to clipboard service…")
|
||||
anchors.centerIn: parent
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
|
||||
@@ -60,15 +60,12 @@ DankModal {
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!clipboardAvailable) {
|
||||
ToastService.showError(I18n.tr("Clipboard service not available"));
|
||||
return;
|
||||
}
|
||||
open();
|
||||
activeImageLoads = 0;
|
||||
shouldHaveFocus = true;
|
||||
ClipboardService.reset();
|
||||
ClipboardService.refresh();
|
||||
if (clipboardAvailable)
|
||||
ClipboardService.refresh();
|
||||
keyboardController.reset();
|
||||
|
||||
Qt.callLater(function () {
|
||||
|
||||
@@ -50,14 +50,11 @@ DankPopout {
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!clipboardAvailable) {
|
||||
ToastService.showError(I18n.tr("Clipboard service not available"));
|
||||
return;
|
||||
}
|
||||
open();
|
||||
activeImageLoads = 0;
|
||||
ClipboardService.reset();
|
||||
ClipboardService.refresh();
|
||||
if (clipboardAvailable)
|
||||
ClipboardService.refresh();
|
||||
keyboardController.reset();
|
||||
|
||||
Qt.callLater(function () {
|
||||
@@ -122,10 +119,10 @@ DankPopout {
|
||||
onBackgroundClicked: hide()
|
||||
|
||||
onShouldBeVisibleChanged: {
|
||||
if (!shouldBeVisible) {
|
||||
if (!shouldBeVisible)
|
||||
return;
|
||||
}
|
||||
ClipboardService.refresh();
|
||||
if (clipboardAvailable)
|
||||
ClipboardService.refresh();
|
||||
keyboardController.reset();
|
||||
Qt.callLater(function () {
|
||||
if (contentLoader.item?.searchField) {
|
||||
|
||||
@@ -352,7 +352,8 @@ Item {
|
||||
searchQuery = query;
|
||||
searchDebounce.restart();
|
||||
|
||||
if (searchMode !== "plugins" && (searchMode === "files" || query.startsWith("/")) && query.length > 0) {
|
||||
var filesInAll = searchMode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll);
|
||||
if (searchMode !== "plugins" && (searchMode === "files" || query.startsWith("/") || filesInAll) && query.length > 0) {
|
||||
fileSearchDebounce.restart();
|
||||
}
|
||||
}
|
||||
@@ -369,7 +370,8 @@ Item {
|
||||
searchMode = mode;
|
||||
modeChanged(mode);
|
||||
performSearch();
|
||||
if (mode === "files") {
|
||||
var filesInAll = mode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll) && searchQuery.length > 0;
|
||||
if (mode === "files" || filesInAll) {
|
||||
fileSearchDebounce.restart();
|
||||
}
|
||||
}
|
||||
@@ -927,10 +929,22 @@ Item {
|
||||
if (!DSearchService.dsearchAvailable)
|
||||
return;
|
||||
var fileQuery = "";
|
||||
var effectiveType = fileSearchType || "all";
|
||||
var includeFiles = SettingsData.dankLauncherV2IncludeFilesInAll;
|
||||
var includeFolders = SettingsData.dankLauncherV2IncludeFoldersInAll;
|
||||
|
||||
if (searchQuery.startsWith("/")) {
|
||||
fileQuery = searchQuery.substring(1).trim();
|
||||
} else if (searchMode === "files") {
|
||||
fileQuery = searchQuery.trim();
|
||||
} else if (searchMode === "all" && (includeFiles || includeFolders)) {
|
||||
fileQuery = searchQuery.trim();
|
||||
if (includeFiles && !includeFolders)
|
||||
effectiveType = "file";
|
||||
else if (!includeFiles && includeFolders)
|
||||
effectiveType = "dir";
|
||||
else
|
||||
effectiveType = "all";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -941,109 +955,129 @@ Item {
|
||||
}
|
||||
|
||||
isFileSearching = true;
|
||||
var params = {
|
||||
limit: 20,
|
||||
fuzzy: true,
|
||||
sort: fileSearchSort || "score",
|
||||
desc: true
|
||||
};
|
||||
|
||||
if (DSearchService.supportsTypeFilter) {
|
||||
params.type = (fileSearchType && fileSearchType !== "all") ? fileSearchType : "all";
|
||||
}
|
||||
if (fileSearchExt) {
|
||||
params.ext = fileSearchExt;
|
||||
}
|
||||
if (fileSearchFolder) {
|
||||
params.folder = fileSearchFolder;
|
||||
}
|
||||
var splitBothTypes = searchMode === "all" && includeFiles && includeFolders && DSearchService.supportsTypeFilter;
|
||||
var queryTypes = splitBothTypes ? ["file", "dir"] : [effectiveType];
|
||||
var pending = queryTypes.length;
|
||||
var aggregatedItems = [];
|
||||
|
||||
DSearchService.search(fileQuery, params, function (response) {
|
||||
isFileSearching = false;
|
||||
if (response.error)
|
||||
return;
|
||||
var fileItems = [];
|
||||
var hits = response.result?.hits || [];
|
||||
for (var t = 0; t < queryTypes.length; t++) {
|
||||
var queryType = queryTypes[t];
|
||||
var params = {
|
||||
limit: 20,
|
||||
fuzzy: true,
|
||||
sort: fileSearchSort || "score",
|
||||
desc: true
|
||||
};
|
||||
|
||||
for (var i = 0; i < hits.length; i++) {
|
||||
var hit = hits[i];
|
||||
var docTypes = hit.locations?.doc_type;
|
||||
var isDir = docTypes ? !!docTypes["dir"] : false;
|
||||
fileItems.push(transformFileResult({
|
||||
path: hit.id || "",
|
||||
score: hit.score || 0,
|
||||
is_dir: isDir
|
||||
}));
|
||||
if (DSearchService.supportsTypeFilter) {
|
||||
params.type = (queryType && queryType !== "all") ? queryType : "all";
|
||||
}
|
||||
if (fileSearchExt) {
|
||||
params.ext = fileSearchExt;
|
||||
}
|
||||
if (fileSearchFolder) {
|
||||
params.folder = fileSearchFolder;
|
||||
}
|
||||
|
||||
var fileSections = [];
|
||||
var showType = fileSearchType || "all";
|
||||
DSearchService.search(fileQuery, params, function (response) {
|
||||
pending--;
|
||||
if (!response.error) {
|
||||
var hits = response.result?.hits || [];
|
||||
for (var i = 0; i < hits.length; i++) {
|
||||
var hit = hits[i];
|
||||
var docTypes = hit.locations?.doc_type;
|
||||
var isDir = docTypes ? !!docTypes["dir"] : false;
|
||||
aggregatedItems.push(transformFileResult({
|
||||
path: hit.id || "",
|
||||
score: hit.score || 0,
|
||||
is_dir: isDir
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (pending > 0)
|
||||
return;
|
||||
|
||||
if (showType === "all" && DSearchService.supportsTypeFilter) {
|
||||
var onlyFiles = [];
|
||||
var onlyDirs = [];
|
||||
for (var j = 0; j < fileItems.length; j++) {
|
||||
if (fileItems[j].data?.is_dir)
|
||||
onlyDirs.push(fileItems[j]);
|
||||
else
|
||||
onlyFiles.push(fileItems[j]);
|
||||
}
|
||||
if (onlyFiles.length > 0) {
|
||||
fileSections.push({
|
||||
id: "files",
|
||||
title: I18n.tr("Files"),
|
||||
icon: "insert_drive_file",
|
||||
priority: 4,
|
||||
items: onlyFiles,
|
||||
collapsed: collapsedSections["files"] || false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
}
|
||||
if (onlyDirs.length > 0) {
|
||||
fileSections.push({
|
||||
id: "folders",
|
||||
title: I18n.tr("Folders"),
|
||||
icon: "folder",
|
||||
priority: 4.1,
|
||||
items: onlyDirs,
|
||||
collapsed: collapsedSections["folders"] || false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var filesIcon = showType === "dir" ? "folder" : showType === "file" ? "insert_drive_file" : "folder";
|
||||
var filesTitle = showType === "dir" ? I18n.tr("Folders") : I18n.tr("Files");
|
||||
if (fileItems.length > 0) {
|
||||
fileSections.push({
|
||||
id: "files",
|
||||
title: filesTitle,
|
||||
icon: filesIcon,
|
||||
priority: 4,
|
||||
items: fileItems,
|
||||
collapsed: collapsedSections["files"] || false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var newSections;
|
||||
if (searchMode === "files") {
|
||||
newSections = fileSections;
|
||||
} else {
|
||||
var existingNonFile = sections.filter(function (s) {
|
||||
return s.id !== "files" && s.id !== "folders";
|
||||
});
|
||||
newSections = existingNonFile.concat(fileSections);
|
||||
}
|
||||
newSections.sort(function (a, b) {
|
||||
return a.priority - b.priority;
|
||||
isFileSearching = false;
|
||||
_applyFileSearchResults(aggregatedItems, effectiveType);
|
||||
});
|
||||
_applyHighlights(newSections, searchQuery);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
selectedFlatIndex = getFirstItemIndex();
|
||||
updateSelectedItem();
|
||||
}
|
||||
}
|
||||
|
||||
function _applyFileSearchResults(fileItems, effectiveType) {
|
||||
var fileSections = [];
|
||||
var showType = effectiveType;
|
||||
var order = SettingsData.launcherPluginOrder || [];
|
||||
var filesOrderIdx = order.indexOf("__files");
|
||||
var foldersOrderIdx = order.indexOf("__folders");
|
||||
var filesPriority = filesOrderIdx !== -1 ? 2.6 + filesOrderIdx * 0.01 : 4;
|
||||
var foldersPriority = foldersOrderIdx !== -1 ? 2.6 + foldersOrderIdx * 0.01 : 4.1;
|
||||
|
||||
if (showType === "all" && DSearchService.supportsTypeFilter) {
|
||||
var onlyFiles = [];
|
||||
var onlyDirs = [];
|
||||
for (var j = 0; j < fileItems.length; j++) {
|
||||
if (fileItems[j].data?.is_dir)
|
||||
onlyDirs.push(fileItems[j]);
|
||||
else
|
||||
onlyFiles.push(fileItems[j]);
|
||||
}
|
||||
if (onlyFiles.length > 0) {
|
||||
fileSections.push({
|
||||
id: "files",
|
||||
title: I18n.tr("Files"),
|
||||
icon: "insert_drive_file",
|
||||
priority: filesPriority,
|
||||
items: onlyFiles,
|
||||
collapsed: collapsedSections["files"] || false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
}
|
||||
if (onlyDirs.length > 0) {
|
||||
fileSections.push({
|
||||
id: "folders",
|
||||
title: I18n.tr("Folders"),
|
||||
icon: "folder",
|
||||
priority: foldersPriority,
|
||||
items: onlyDirs,
|
||||
collapsed: collapsedSections["folders"] || false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var filesIcon = showType === "dir" ? "folder" : showType === "file" ? "insert_drive_file" : "folder";
|
||||
var filesTitle = showType === "dir" ? I18n.tr("Folders") : I18n.tr("Files");
|
||||
var singlePriority = showType === "dir" ? foldersPriority : filesPriority;
|
||||
if (fileItems.length > 0) {
|
||||
fileSections.push({
|
||||
id: "files",
|
||||
title: filesTitle,
|
||||
icon: filesIcon,
|
||||
priority: singlePriority,
|
||||
items: fileItems,
|
||||
collapsed: collapsedSections["files"] || false,
|
||||
flatStartIndex: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var newSections;
|
||||
if (searchMode === "files") {
|
||||
newSections = fileSections;
|
||||
} else {
|
||||
var existingNonFile = sections.filter(function (s) {
|
||||
return s.id !== "files" && s.id !== "folders";
|
||||
});
|
||||
newSections = existingNonFile.concat(fileSections);
|
||||
}
|
||||
newSections.sort(function (a, b) {
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
_applyHighlights(newSections, searchQuery);
|
||||
flatModel = Scorer.flattenSections(newSections);
|
||||
sections = newSections;
|
||||
selectedFlatIndex = getFirstItemIndex();
|
||||
updateSelectedItem();
|
||||
}
|
||||
|
||||
function searchApps(query) {
|
||||
@@ -1276,7 +1310,11 @@ Item {
|
||||
function buildDynamicSectionDefs(items) {
|
||||
var baseDefs = sectionDefinitions.slice();
|
||||
var pluginSections = {};
|
||||
var basePriority = 2.6;
|
||||
var order = SettingsData.launcherPluginOrder || [];
|
||||
var orderMap = {};
|
||||
for (var k = 0; k < order.length; k++)
|
||||
orderMap[order[k]] = k;
|
||||
var unorderedPriority = 2.6 + order.length * 0.01;
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var section = items[i].section;
|
||||
@@ -1287,19 +1325,25 @@ Item {
|
||||
var pluginId = section.substring(7);
|
||||
var meta = getPluginMetadata(pluginId);
|
||||
var viewPref = getPluginViewPref(pluginId);
|
||||
var orderIdx = orderMap[pluginId];
|
||||
var priority;
|
||||
if (orderIdx !== undefined) {
|
||||
priority = 2.6 + orderIdx * 0.01;
|
||||
} else {
|
||||
priority = unorderedPriority;
|
||||
unorderedPriority += 0.01;
|
||||
}
|
||||
|
||||
pluginSections[section] = {
|
||||
id: section,
|
||||
title: meta.name,
|
||||
icon: meta.icon,
|
||||
priority: basePriority,
|
||||
priority: priority,
|
||||
defaultViewMode: viewPref.mode || "list"
|
||||
};
|
||||
|
||||
if (viewPref.mode)
|
||||
setPluginViewPreference(section, viewPref.mode, viewPref.enforced);
|
||||
|
||||
basePriority += 0.01;
|
||||
}
|
||||
|
||||
for (var sectionId in pluginSections) {
|
||||
|
||||
@@ -372,10 +372,10 @@ Popup {
|
||||
anchors.fill: parent
|
||||
implicitWidth: Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2)
|
||||
implicitHeight: menuColumn.implicitHeight + Theme.spacingS * 2
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
color: BlurService.enabled ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: 1
|
||||
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
@@ -438,7 +438,7 @@ Popup {
|
||||
if (root.keyboardNavigation && root.selectedMenuIndex === menuItemDelegate.itemIndex) {
|
||||
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2);
|
||||
}
|
||||
return itemMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent";
|
||||
return itemMouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
|
||||
}
|
||||
|
||||
Row {
|
||||
|
||||
@@ -144,10 +144,48 @@ DankModal {
|
||||
return "NOTIFICATION_MODAL_TOGGLE_DND_SUCCESS";
|
||||
}
|
||||
|
||||
function enableDoNotDisturbFor(minutes: int): string {
|
||||
if (minutes <= 0) {
|
||||
return "ERROR: minutes must be > 0";
|
||||
}
|
||||
SessionData.setDoNotDisturb(true, minutes);
|
||||
return "NOTIFICATION_MODAL_DND_SET_FOR_" + minutes + "_SUCCESS";
|
||||
}
|
||||
|
||||
function enableDoNotDisturbUntil(timestampMs: string): string {
|
||||
const ts = Number(timestampMs);
|
||||
if (!ts || ts <= Date.now()) {
|
||||
return "ERROR: timestamp must be a future epoch ms";
|
||||
}
|
||||
SessionData.setDoNotDisturbUntilTimestamp(ts);
|
||||
return "NOTIFICATION_MODAL_DND_SET_UNTIL_SUCCESS";
|
||||
}
|
||||
|
||||
function enableDoNotDisturbIndefinitely(): string {
|
||||
SessionData.setDoNotDisturb(true, 0);
|
||||
return "NOTIFICATION_MODAL_DND_INDEFINITE_SUCCESS";
|
||||
}
|
||||
|
||||
function enableDoNotDisturbUntilTomorrowMorning(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 8, 0, 0, 0);
|
||||
SessionData.setDoNotDisturbUntilTimestamp(target.getTime());
|
||||
return "NOTIFICATION_MODAL_DND_UNTIL_TOMORROW_SUCCESS";
|
||||
}
|
||||
|
||||
function disableDoNotDisturb(): string {
|
||||
SessionData.setDoNotDisturb(false);
|
||||
return "NOTIFICATION_MODAL_DND_DISABLE_SUCCESS";
|
||||
}
|
||||
|
||||
function getDoNotDisturb(): bool {
|
||||
return SessionData.doNotDisturb;
|
||||
}
|
||||
|
||||
function getDoNotDisturbUntil(): string {
|
||||
return String(SessionData.doNotDisturbUntil);
|
||||
}
|
||||
|
||||
function clearAll(): string {
|
||||
notificationModal.clearAll();
|
||||
return "NOTIFICATION_MODAL_CLEAR_ALL_SUCCESS";
|
||||
|
||||
@@ -58,7 +58,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
property bool enabled: isInstance ? (instanceData?.enabled ?? true) : SettingsData.desktopClockEnabled
|
||||
enabled: isInstance ? (instanceData?.enabled ?? true) : SettingsData.desktopClockEnabled
|
||||
property real transparency: isInstance ? (cfg.transparency ?? 0.8) : SettingsData.desktopClockTransparency
|
||||
property string colorMode: isInstance ? (cfg.colorMode ?? "primary") : SettingsData.desktopClockColorMode
|
||||
property color customColor: isInstance ? (cfg.customColor ?? "#ffffff") : SettingsData.desktopClockCustomColor
|
||||
|
||||
@@ -37,7 +37,7 @@ Item {
|
||||
readonly property var cfg: instanceData?.config ?? null
|
||||
readonly property bool isInstance: instanceId !== "" && cfg !== null
|
||||
|
||||
property bool enabled: isInstance ? (instanceData?.enabled ?? true) : SettingsData.systemMonitorEnabled
|
||||
enabled: isInstance ? (instanceData?.enabled ?? true) : SettingsData.systemMonitorEnabled
|
||||
property bool showHeader: isInstance ? (cfg.showHeader ?? true) : SettingsData.systemMonitorShowHeader
|
||||
property real transparency: isInstance ? (cfg.transparency ?? 0.8) : SettingsData.systemMonitorTransparency
|
||||
property string colorMode: isInstance ? (cfg.colorMode ?? "primary") : SettingsData.systemMonitorColorMode
|
||||
|
||||
@@ -12,7 +12,6 @@ Rectangle {
|
||||
property string text: ""
|
||||
property string secondaryText: ""
|
||||
property bool isActive: false
|
||||
property bool enabled: true
|
||||
property int widgetIndex: 0
|
||||
property var widgetData: null
|
||||
property bool editMode: false
|
||||
|
||||
@@ -188,6 +188,9 @@ Item {
|
||||
case "battery":
|
||||
coreDetailLoader.sourceComponent = batteryDetailComponent;
|
||||
break;
|
||||
case "doNotDisturb":
|
||||
coreDetailLoader.sourceComponent = doNotDisturbDetailComponent;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -230,6 +233,11 @@ Item {
|
||||
BatteryDetail {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: doNotDisturbDetailComponent
|
||||
DoNotDisturbDetail {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: diskUsageDetailComponent
|
||||
DiskUsageDetail {
|
||||
|
||||
@@ -163,6 +163,8 @@ Column {
|
||||
return widgetWidth <= 25 ? smallDiskUsageComponent : diskUsagePillComponent;
|
||||
} else if (id === "colorPicker") {
|
||||
return colorPickerPillComponent;
|
||||
} else if (id === "doNotDisturb") {
|
||||
return widgetWidth <= 25 ? smallToggleComponent : dndPillComponent;
|
||||
} else {
|
||||
return widgetWidth <= 25 ? smallToggleComponent : toggleButtonComponent;
|
||||
}
|
||||
@@ -260,7 +262,7 @@ Column {
|
||||
}
|
||||
case "audioOutput":
|
||||
{
|
||||
if (!AudioService.sink)
|
||||
if (!AudioService.sink?.audio)
|
||||
return "volume_off";
|
||||
let volume = AudioService.sink.audio.volume;
|
||||
let muted = AudioService.sink.audio.muted;
|
||||
@@ -276,7 +278,7 @@ Column {
|
||||
}
|
||||
case "audioInput":
|
||||
{
|
||||
if (!AudioService.source)
|
||||
if (!AudioService.source?.audio)
|
||||
return "mic_off";
|
||||
let muted = AudioService.source.audio.muted;
|
||||
return muted ? "mic_off" : "mic";
|
||||
@@ -369,7 +371,7 @@ Column {
|
||||
}
|
||||
case "audioOutput":
|
||||
{
|
||||
if (!AudioService.sink)
|
||||
if (!AudioService.sink?.audio)
|
||||
return I18n.tr("Select device", "audio status");
|
||||
if (AudioService.sink.audio.muted)
|
||||
return I18n.tr("Muted", "audio status");
|
||||
@@ -380,7 +382,7 @@ Column {
|
||||
}
|
||||
case "audioInput":
|
||||
{
|
||||
if (!AudioService.source)
|
||||
if (!AudioService.source?.audio)
|
||||
return I18n.tr("Select device", "audio status");
|
||||
if (AudioService.source.audio.muted)
|
||||
return I18n.tr("Muted", "audio status");
|
||||
@@ -412,9 +414,9 @@ Column {
|
||||
case "bluetooth":
|
||||
return !!(BluetoothService.available && BluetoothService.adapter && BluetoothService.adapter.enabled);
|
||||
case "audioOutput":
|
||||
return !!(AudioService.sink && !AudioService.sink.audio.muted);
|
||||
return !!(AudioService.sink?.audio && !AudioService.sink.audio.muted);
|
||||
case "audioInput":
|
||||
return !!(AudioService.source && !AudioService.source.audio.muted);
|
||||
return !!(AudioService.source?.audio && !AudioService.source.audio.muted);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -573,6 +575,22 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: dndPillComponent
|
||||
DndPill {
|
||||
property var widgetData: parent.widgetData || {}
|
||||
property int widgetIndex: parent.widgetIndex || 0
|
||||
width: parent.width
|
||||
height: 60
|
||||
|
||||
onExpandClicked: {
|
||||
if (!root.editMode) {
|
||||
root.expandClicked(widgetData, widgetIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: smallBatteryComponent
|
||||
SmallBatteryButton {
|
||||
@@ -603,8 +621,6 @@ Column {
|
||||
return DisplayService.nightModeEnabled ? "nightlight" : "dark_mode";
|
||||
case "darkMode":
|
||||
return "contrast";
|
||||
case "doNotDisturb":
|
||||
return SessionData.doNotDisturb ? "do_not_disturb_on" : "do_not_disturb_off";
|
||||
case "idleInhibitor":
|
||||
return SessionService.idleInhibited ? "motion_sensor_active" : "motion_sensor_idle";
|
||||
default:
|
||||
@@ -618,8 +634,6 @@ Column {
|
||||
return I18n.tr("Night Mode");
|
||||
case "darkMode":
|
||||
return I18n.tr("Dark Mode");
|
||||
case "doNotDisturb":
|
||||
return I18n.tr("Do Not Disturb");
|
||||
case "idleInhibitor":
|
||||
return SessionService.idleInhibited ? I18n.tr("Keeping Awake") : I18n.tr("Keep Awake");
|
||||
default:
|
||||
@@ -642,8 +656,6 @@ Column {
|
||||
return DisplayService.nightModeEnabled || false;
|
||||
case "darkMode":
|
||||
return !SessionData.isLightMode;
|
||||
case "doNotDisturb":
|
||||
return SessionData.doNotDisturb || false;
|
||||
case "idleInhibitor":
|
||||
return SessionService.idleInhibited || false;
|
||||
default:
|
||||
@@ -670,11 +682,6 @@ Column {
|
||||
Theme.setLightMode(newMode);
|
||||
break;
|
||||
}
|
||||
case "doNotDisturb":
|
||||
{
|
||||
SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
|
||||
break;
|
||||
}
|
||||
case "idleInhibitor":
|
||||
{
|
||||
SessionService.toggleIdleInhibit();
|
||||
|
||||
@@ -351,8 +351,8 @@ Rectangle {
|
||||
deviceRipple.trigger(mapped.x, mapped.y);
|
||||
}
|
||||
onClicked: {
|
||||
if (modelData) {
|
||||
Pipewire.preferredDefaultAudioSource = modelData;
|
||||
if (modelData && modelData.name) {
|
||||
AudioService.setDefaultSourceByName(modelData.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,8 +355,8 @@ Rectangle {
|
||||
deviceRipple.trigger(mapped.x, mapped.y);
|
||||
}
|
||||
onClicked: {
|
||||
if (modelData) {
|
||||
Pipewire.preferredDefaultAudioSink = modelData;
|
||||
if (modelData && modelData.name) {
|
||||
AudioService.setDefaultSinkByName(modelData.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
258
quickshell/Modules/ControlCenter/Details/DoNotDisturbDetail.qml
Normal file
258
quickshell/Modules/ControlCenter/Details/DoNotDisturbDetail.qml
Normal file
@@ -0,0 +1,258 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
implicitHeight: contentColumn.implicitHeight + Theme.spacingL * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: 0
|
||||
|
||||
property real nowMs: Date.now()
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: root.visible && SessionData.doNotDisturb && SessionData.doNotDisturbUntil > 0
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
|
||||
function _pad2(n) {
|
||||
return n < 10 ? "0" + n : "" + n;
|
||||
}
|
||||
|
||||
function formatUntil(ts) {
|
||||
if (!ts)
|
||||
return "";
|
||||
const d = new Date(ts);
|
||||
const use24h = (typeof SettingsData !== "undefined") ? SettingsData.use24HourClock : true;
|
||||
if (use24h)
|
||||
return _pad2(d.getHours()) + ":" + _pad2(d.getMinutes());
|
||||
const suffix = d.getHours() >= 12 ? "PM" : "AM";
|
||||
const h12 = ((d.getHours() + 11) % 12) + 1;
|
||||
return h12 + ":" + _pad2(d.getMinutes()) + " " + suffix;
|
||||
}
|
||||
|
||||
function formatRemaining(ms) {
|
||||
if (ms <= 0)
|
||||
return "";
|
||||
const totalMinutes = Math.ceil(ms / 60000);
|
||||
if (totalMinutes < 60)
|
||||
return I18n.tr("%1 min left").arg(totalMinutes);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const mins = totalMinutes - hours * 60;
|
||||
if (mins === 0)
|
||||
return I18n.tr("%1 h left").arg(hours);
|
||||
return I18n.tr("%1 h %2 m left").arg(hours).arg(mins);
|
||||
}
|
||||
|
||||
function minutesUntilTomorrowMorning() {
|
||||
const now = new Date();
|
||||
const target = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 8, 0, 0, 0);
|
||||
return Math.max(1, Math.round((target.getTime() - now.getTime()) / 60000));
|
||||
}
|
||||
|
||||
readonly property var presets: [
|
||||
{
|
||||
"label": I18n.tr("15 min"),
|
||||
"minutes": 15
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("30 min"),
|
||||
"minutes": 30
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("1 hour"),
|
||||
"minutes": 60
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("3 hours"),
|
||||
"minutes": 180
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("8 hours"),
|
||||
"minutes": 480
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("Until 8 AM"),
|
||||
"minutesFn": true
|
||||
}
|
||||
]
|
||||
|
||||
Column {
|
||||
id: contentColumn
|
||||
width: parent.width - Theme.spacingL * 2
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: SessionData.doNotDisturb ? "do_not_disturb_on" : "notifications_paused"
|
||||
size: Theme.iconSizeLarge
|
||||
color: SessionData.doNotDisturb ? Theme.primary : Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - Theme.iconSizeLarge - Theme.spacingM
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Silence notifications")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (!SessionData.doNotDisturb)
|
||||
return I18n.tr("Pick how long to pause notifications");
|
||||
if (SessionData.doNotDisturbUntil <= 0)
|
||||
return I18n.tr("On indefinitely");
|
||||
const remaining = Math.max(0, SessionData.doNotDisturbUntil - root.nowMs);
|
||||
return root.formatRemaining(remaining) + " · " + I18n.tr("until %1").arg(root.formatUntil(SessionData.doNotDisturbUntil));
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
width: parent.width
|
||||
columns: 3
|
||||
columnSpacing: Theme.spacingS
|
||||
rowSpacing: Theme.spacingS
|
||||
|
||||
Repeater {
|
||||
model: root.presets
|
||||
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
width: (contentColumn.width - Theme.spacingS * 2) / 3
|
||||
height: 36
|
||||
radius: Theme.cornerRadius
|
||||
color: presetArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
||||
border.width: 1
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.label
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: presetArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
const minutes = modelData.minutesFn ? root.minutesUntilTomorrowMorning() : modelData.minutes;
|
||||
SessionData.setDoNotDisturb(true, minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Rectangle {
|
||||
width: (contentColumn.width - Theme.spacingS) / 2
|
||||
height: 36
|
||||
radius: Theme.cornerRadius
|
||||
color: foreverArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
||||
border.width: 1
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "block"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: I18n.tr("Until I turn it off")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: foreverArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: SessionData.setDoNotDisturb(true, 0)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: (contentColumn.width - Theme.spacingS) / 2
|
||||
height: 36
|
||||
radius: Theme.cornerRadius
|
||||
visible: SessionData.doNotDisturb
|
||||
color: offArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.18) : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
||||
border.width: 1
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "notifications_active"
|
||||
size: Theme.iconSizeSmall
|
||||
color: offArea.containsMouse ? Theme.error : Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: I18n.tr("Turn off")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: offArea.containsMouse ? Theme.error : Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: offArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: SessionData.setDoNotDisturb(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ Row {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => iconRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: {
|
||||
if (defaultSink) {
|
||||
if (defaultSink?.audio) {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
defaultSink.audio.muted = !defaultSink.audio.muted;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ Row {
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: {
|
||||
if (!defaultSink)
|
||||
if (!defaultSink?.audio)
|
||||
return "volume_off";
|
||||
|
||||
let volume = defaultSink.audio.volume;
|
||||
@@ -62,18 +62,18 @@ Row {
|
||||
return "volume_up";
|
||||
}
|
||||
size: Theme.iconSize
|
||||
color: defaultSink && !defaultSink.audio.muted && defaultSink.audio.volume > 0 ? Theme.primary : Theme.surfaceText
|
||||
color: defaultSink?.audio && !defaultSink.audio.muted && defaultSink.audio.volume > 0 ? Theme.primary : Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
DankSlider {
|
||||
id: volumeSlider
|
||||
|
||||
readonly property real actualVolumePercent: defaultSink ? Math.round(defaultSink.audio.volume * 100) : 0
|
||||
readonly property real actualVolumePercent: defaultSink?.audio ? Math.round(defaultSink.audio.volume * 100) : 0
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - (Theme.iconSize + Theme.spacingS * 2)
|
||||
enabled: defaultSink !== null
|
||||
enabled: defaultSink?.audio != null
|
||||
minimum: 0
|
||||
maximum: AudioService.sinkMaxVolume
|
||||
showValue: true
|
||||
@@ -83,7 +83,7 @@ Row {
|
||||
trackColor: root.sliderTrackColor.a > 0 ? root.sliderTrackColor : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
|
||||
onSliderValueChanged: function (newValue) {
|
||||
if (defaultSink) {
|
||||
if (defaultSink?.audio) {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
defaultSink.audio.volume = newValue / 100.0;
|
||||
if (newValue > 0 && defaultSink.audio.muted) {
|
||||
@@ -97,7 +97,7 @@ Row {
|
||||
Binding {
|
||||
target: volumeSlider
|
||||
property: "value"
|
||||
value: defaultSink ? Math.min(AudioService.sinkMaxVolume, Math.round(defaultSink.audio.volume * 100)) : 0
|
||||
value: defaultSink?.audio ? Math.min(AudioService.sinkMaxVolume, Math.round(defaultSink.audio.volume * 100)) : 0
|
||||
when: !volumeSlider.isDragging
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ Rectangle {
|
||||
property real value: 0.0
|
||||
property real maximumValue: 1.0
|
||||
property real minimumValue: 0.0
|
||||
property bool enabled: true
|
||||
|
||||
signal sliderValueChanged(real value)
|
||||
|
||||
|
||||
29
quickshell/Modules/ControlCenter/Widgets/DndPill.qml
Normal file
29
quickshell/Modules/ControlCenter/Widgets/DndPill.qml
Normal file
@@ -0,0 +1,29 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modules.ControlCenter.Widgets
|
||||
|
||||
CompoundPill {
|
||||
id: root
|
||||
|
||||
iconName: SessionData.doNotDisturb ? "do_not_disturb_on" : "do_not_disturb_off"
|
||||
iconColor: SessionData.doNotDisturb ? Theme.primary : Theme.surfaceText
|
||||
primaryText: I18n.tr("Do Not Disturb")
|
||||
isActive: SessionData.doNotDisturb
|
||||
|
||||
secondaryText: {
|
||||
if (!SessionData.doNotDisturb)
|
||||
return I18n.tr("Off");
|
||||
if (SessionData.doNotDisturbUntil <= 0)
|
||||
return I18n.tr("On");
|
||||
const d = new Date(SessionData.doNotDisturbUntil);
|
||||
const use24h = (typeof SettingsData !== "undefined") ? SettingsData.use24HourClock : true;
|
||||
const pad = n => n < 10 ? "0" + n : "" + n;
|
||||
if (use24h)
|
||||
return I18n.tr("Until %1").arg(pad(d.getHours()) + ":" + pad(d.getMinutes()));
|
||||
const suffix = d.getHours() >= 12 ? "PM" : "AM";
|
||||
const h12 = ((d.getHours() + 11) % 12) + 1;
|
||||
return I18n.tr("Until %1").arg(h12 + ":" + pad(d.getMinutes()) + " " + suffix);
|
||||
}
|
||||
|
||||
onToggled: SessionData.setDoNotDisturb(!SessionData.doNotDisturb)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ Row {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => iconRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: {
|
||||
if (defaultSource) {
|
||||
if (defaultSource?.audio) {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
defaultSource.audio.muted = !defaultSource.audio.muted;
|
||||
}
|
||||
@@ -45,7 +45,7 @@ Row {
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: {
|
||||
if (!defaultSource)
|
||||
if (!defaultSource?.audio)
|
||||
return "mic_off";
|
||||
|
||||
let volume = defaultSource.audio.volume;
|
||||
@@ -56,26 +56,26 @@ Row {
|
||||
return "mic";
|
||||
}
|
||||
size: Theme.iconSize
|
||||
color: defaultSource && !defaultSource.audio.muted && defaultSource.audio.volume > 0 ? Theme.primary : Theme.surfaceText
|
||||
color: defaultSource?.audio && !defaultSource.audio.muted && defaultSource.audio.volume > 0 ? Theme.primary : Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
DankSlider {
|
||||
readonly property real actualVolumePercent: defaultSource ? Math.round(defaultSource.audio.volume * 100) : 0
|
||||
readonly property real actualVolumePercent: defaultSource?.audio ? Math.round(defaultSource.audio.volume * 100) : 0
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - (Theme.iconSize + Theme.spacingS * 2)
|
||||
enabled: defaultSource !== null
|
||||
enabled: defaultSource?.audio != null
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
value: defaultSource ? Math.min(100, Math.round(defaultSource.audio.volume * 100)) : 0
|
||||
value: defaultSource?.audio ? Math.min(100, Math.round(defaultSource.audio.volume * 100)) : 0
|
||||
showValue: true
|
||||
unit: "%"
|
||||
valueOverride: actualVolumePercent
|
||||
thumbOutlineColor: Theme.surfaceContainer
|
||||
trackColor: root.sliderTrackColor.a > 0 ? root.sliderTrackColor : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
onSliderValueChanged: function (newValue) {
|
||||
if (defaultSource) {
|
||||
if (defaultSource?.audio) {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
defaultSource.audio.volume = newValue / 100.0;
|
||||
if (newValue > 0 && defaultSource.audio.muted) {
|
||||
|
||||
@@ -10,7 +10,7 @@ Rectangle {
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
property bool isActive: BatteryService.batteryAvailable && (BatteryService.isCharging || BatteryService.isPluggedIn)
|
||||
property bool enabled: BatteryService.batteryAvailable
|
||||
enabled: BatteryService.batteryAvailable
|
||||
|
||||
signal clicked
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Rectangle {
|
||||
return parseFloat(selectedMount.percent.replace("%", "")) || 0;
|
||||
}
|
||||
|
||||
property bool enabled: DgopService.dgopAvailable
|
||||
enabled: DgopService.dgopAvailable
|
||||
|
||||
signal clicked
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ Rectangle {
|
||||
|
||||
property string iconName: ""
|
||||
property bool isActive: false
|
||||
property bool enabled: true
|
||||
property real iconRotation: 0
|
||||
|
||||
signal clicked
|
||||
|
||||
@@ -11,7 +11,6 @@ Rectangle {
|
||||
property string iconName: ""
|
||||
property string text: ""
|
||||
property bool isActive: false
|
||||
property bool enabled: true
|
||||
property string secondaryText: ""
|
||||
property real iconRotation: 0
|
||||
|
||||
|
||||
@@ -969,6 +969,7 @@ Item {
|
||||
axis: barWindow.axis
|
||||
barSpacing: barConfig?.spacing ?? 4
|
||||
barConfig: topBarContent.barConfig
|
||||
widgetData: parent.widgetData
|
||||
isAutoHideBar: topBarContent.barConfig?.autoHide ?? false
|
||||
isAtBottom: barWindow.axis?.edge === "bottom"
|
||||
visible: SettingsData.getFilteredScreens("systemTray").includes(barWindow.screen) && SystemTray.items.values.length > 0
|
||||
@@ -1437,12 +1438,21 @@ Item {
|
||||
parentScreen: barWindow.screen
|
||||
onClicked: {
|
||||
systemUpdateLoader.active = true;
|
||||
if (!systemUpdateLoader.item)
|
||||
return;
|
||||
const popout = systemUpdateLoader.item;
|
||||
const effectiveBarConfig = topBarContent.barConfig;
|
||||
const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1));
|
||||
if (systemUpdateLoader.item && systemUpdateLoader.item.setBarContext) {
|
||||
systemUpdateLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
|
||||
if (popout.setBarContext) {
|
||||
popout.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
|
||||
}
|
||||
systemUpdateLoader.item?.toggle();
|
||||
if (popout.setTriggerPosition) {
|
||||
const globalPos = visualContent.mapToItem(null, 0, 0);
|
||||
const currentScreen = parentScreen || Screen;
|
||||
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barWindow.effectiveBarThickness, visualWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig);
|
||||
popout.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig);
|
||||
}
|
||||
PopoutManager.requestPopout(popout, undefined, "systemUpdate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,15 @@ import qs.Widgets
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: root
|
||||
blurX: menuContainer.x
|
||||
blurY: menuContainer.y
|
||||
blurWidth: root.visible ? menuContainer.width : 0
|
||||
blurHeight: root.visible ? menuContainer.height : 0
|
||||
blurRadius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "dms:dock-context-menu"
|
||||
|
||||
property var appData: null
|
||||
@@ -112,8 +121,8 @@ PanelWindow {
|
||||
height: Math.max(60, menuColumn.implicitHeight + Theme.spacingS * 2)
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: 1
|
||||
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
||||
|
||||
opacity: root.visible ? 1 : 0
|
||||
visible: opacity > 0
|
||||
@@ -165,7 +174,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: windowArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: windowArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
@@ -255,7 +264,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: actionArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: actionArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
@@ -330,7 +339,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: pinArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: pinArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
@@ -390,7 +399,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: nvidiaArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: nvidiaArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Common
|
||||
import qs.Modules.Plugins
|
||||
import qs.Services
|
||||
@@ -10,6 +11,8 @@ BasePill {
|
||||
property bool batteryPopupVisible: false
|
||||
property var popoutTarget: null
|
||||
|
||||
property real touchpadAccumulator: 0
|
||||
|
||||
readonly property int barPosition: {
|
||||
switch (axis?.edge) {
|
||||
case "top":
|
||||
@@ -119,5 +122,44 @@ BasePill {
|
||||
battery.triggerRipple(this, mouse.x, mouse.y);
|
||||
toggleBatteryPopup();
|
||||
}
|
||||
onWheel: wheel => {
|
||||
var delta = wheel.angleDelta.y;
|
||||
if (delta === 0)
|
||||
return;
|
||||
|
||||
// Check if this is a touchpad
|
||||
if (delta !== 120 && delta !== -120) {
|
||||
touchpadAccumulator += delta;
|
||||
console.info("Acc: "+touchpadAccumulator);
|
||||
if (Math.abs(touchpadAccumulator) < 500)
|
||||
return;
|
||||
delta = touchpadAccumulator;
|
||||
touchpadAccumulator = 0;
|
||||
}
|
||||
console.info("Trigger! Delta: "+delta)
|
||||
|
||||
// This is after the other delta checks so it only shows on valid Y scroll
|
||||
if (typeof PowerProfiles === "undefined") {
|
||||
ToastService.showError("power-profiles-daemon not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get list of profiles, and current index
|
||||
const profiles = [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []);
|
||||
var index = profiles.findIndex(profile => PowerProfiles.profile === profile);
|
||||
|
||||
// Step once based on mouse wheel direction
|
||||
if (delta > 0) index += 1;
|
||||
else index -= 1;
|
||||
|
||||
// Already at end of list, can't go further
|
||||
if (index < 0 || index >= profiles.length) return;
|
||||
|
||||
// Set new profile
|
||||
PowerProfiles.profile = profiles[index];
|
||||
if (PowerProfiles.profile !== profiles[index]) {
|
||||
ToastService.showError("Failed to set power profile");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ BasePill {
|
||||
StyledTextMetrics {
|
||||
id: cpuBaseline
|
||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
text: "88%"
|
||||
text: "100%"
|
||||
}
|
||||
|
||||
StyledTextMetrics {
|
||||
|
||||
@@ -17,7 +17,7 @@ BasePill {
|
||||
property int availableWidth: 400
|
||||
readonly property int maxNormalWidth: 456
|
||||
readonly property int maxCompactWidth: 288
|
||||
readonly property Toplevel activeWindow: ToplevelManager.activeToplevel
|
||||
property Toplevel activeWindow: null
|
||||
property var activeDesktopEntry: null
|
||||
property bool isHovered: mouseArea.containsMouse
|
||||
property bool isAutoHideBar: false
|
||||
@@ -38,10 +38,44 @@ BasePill {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function updateActiveWindow() {
|
||||
const active = ToplevelManager.activeToplevel;
|
||||
|
||||
if (!active) {
|
||||
// Only clear if our tracked window is no longer alive
|
||||
if (activeWindow) {
|
||||
const alive = ToplevelManager.toplevels?.values;
|
||||
if (alive && !Array.from(alive).some(t => t === activeWindow))
|
||||
activeWindow = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parentScreen || CompositorService.filterCurrentDisplay([active], parentScreen?.name)?.length > 0) {
|
||||
activeWindow = active;
|
||||
}
|
||||
// else: active window is on a different screen so keep the previous value
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
updateActiveWindow();
|
||||
updateDesktopEntry();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ToplevelManager
|
||||
function onActiveToplevelChanged() {
|
||||
root.updateActiveWindow();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onToplevelsChanged() {
|
||||
root.updateActiveWindow();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DesktopEntries
|
||||
function onApplicationsChanged() {
|
||||
|
||||
@@ -19,7 +19,8 @@ BasePill {
|
||||
readonly property bool usePlayerVolume: activePlayer && activePlayer.volumeSupported && !__isChromeBrowser
|
||||
property bool compactMode: false
|
||||
property var widgetData: null
|
||||
readonly property int textWidth: {
|
||||
readonly property bool adaptiveWidthEnabled: SettingsData.mediaAdaptiveWidthEnabled
|
||||
readonly property int maxTextWidth: {
|
||||
const size = widgetData?.mediaSize !== undefined ? widgetData.mediaSize : SettingsData.mediaSize;
|
||||
switch (size) {
|
||||
case 0:
|
||||
@@ -36,10 +37,7 @@ BasePill {
|
||||
if (isVerticalOrientation) {
|
||||
return widgetThickness - horizontalPadding * 2;
|
||||
}
|
||||
const controlsWidth = 20 + Theme.spacingXS + 24 + Theme.spacingXS + 20;
|
||||
const audioVizWidth = 20;
|
||||
const contentWidth = audioVizWidth + Theme.spacingXS + controlsWidth;
|
||||
return contentWidth + (textWidth > 0 ? textWidth + Theme.spacingXS : 0);
|
||||
return 0;
|
||||
}
|
||||
readonly property int currentContentHeight: {
|
||||
if (!isVerticalOrientation) {
|
||||
@@ -99,7 +97,7 @@ BasePill {
|
||||
|
||||
if (isMouseWheelY) {
|
||||
if (deltaY > 0) {
|
||||
activePlayer.previous();
|
||||
MprisController.previousOrRewind();
|
||||
} else {
|
||||
activePlayer.next();
|
||||
}
|
||||
@@ -107,7 +105,7 @@ BasePill {
|
||||
scrollAccumulatorY += deltaY;
|
||||
if (Math.abs(scrollAccumulatorY) >= touchpadThreshold) {
|
||||
if (scrollAccumulatorY > 0) {
|
||||
activePlayer.previous();
|
||||
MprisController.previousOrRewind();
|
||||
} else {
|
||||
activePlayer.next();
|
||||
}
|
||||
@@ -119,7 +117,28 @@ BasePill {
|
||||
|
||||
content: Component {
|
||||
Item {
|
||||
implicitWidth: root.playerAvailable ? root.currentContentWidth : 0
|
||||
id: contentRoot
|
||||
readonly property real measuredTextWidth: {
|
||||
if (!root.playerAvailable || root.maxTextWidth <= 0 || !textContainer.visible)
|
||||
return 0;
|
||||
// Preserve the fixed-width text slot even if metadata is briefly empty.
|
||||
if (!root.adaptiveWidthEnabled)
|
||||
return root.maxTextWidth;
|
||||
if (textContainer.displayText.length === 0)
|
||||
return 0;
|
||||
const rawWidth = mediaText.contentWidth;
|
||||
if (!isFinite(rawWidth) || rawWidth <= 0)
|
||||
return 0;
|
||||
return Math.min(root.maxTextWidth, Math.ceil(rawWidth));
|
||||
}
|
||||
readonly property int horizontalContentWidth: {
|
||||
const controlsWidth = 20 + Theme.spacingXS + 24 + Theme.spacingXS + 20;
|
||||
const audioVizWidth = 20;
|
||||
const baseWidth = audioVizWidth + Theme.spacingXS + controlsWidth;
|
||||
return baseWidth + (measuredTextWidth > 0 ? measuredTextWidth + Theme.spacingXS : 0);
|
||||
}
|
||||
|
||||
implicitWidth: root.playerAvailable ? (root.isVerticalOrientation ? root.currentContentWidth : horizontalContentWidth) : 0
|
||||
implicitHeight: root.playerAvailable ? root.currentContentHeight : 0
|
||||
opacity: root.playerAvailable ? 1 : 0
|
||||
|
||||
@@ -132,8 +151,9 @@ BasePill {
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +234,7 @@ BasePill {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
activePlayer.togglePlaying();
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
activePlayer.previous();
|
||||
MprisController.previousOrRewind();
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
activePlayer.next();
|
||||
}
|
||||
@@ -269,7 +289,7 @@ BasePill {
|
||||
}
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: textWidth
|
||||
width: contentRoot.measuredTextWidth
|
||||
height: root.widgetThickness
|
||||
visible: {
|
||||
const size = widgetData?.mediaSize !== undefined ? widgetData.mediaSize : SettingsData.mediaSize;
|
||||
@@ -278,50 +298,95 @@ BasePill {
|
||||
clip: true
|
||||
color: "transparent"
|
||||
|
||||
StyledText {
|
||||
id: mediaText
|
||||
property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled
|
||||
property real scrollOffset: 0
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: textContainer.displayText
|
||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
color: Theme.widgetTextColor
|
||||
wrapMode: Text.NoWrap
|
||||
x: needsScrolling ? -scrollOffset : 0
|
||||
onTextChanged: {
|
||||
scrollOffset = 0;
|
||||
scrollAnimation.restart();
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||
}
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: scrollAnimation
|
||||
running: mediaText.needsScrolling && textContainer.visible
|
||||
loops: Animation.Infinite
|
||||
Item {
|
||||
id: textClip
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
PauseAnimation {
|
||||
duration: 2000
|
||||
StyledText {
|
||||
id: mediaText
|
||||
property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled
|
||||
property real scrollOffset: 0
|
||||
property real textShift: 0
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: textContainer.displayText
|
||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
color: Theme.widgetTextColor
|
||||
wrapMode: Text.NoWrap
|
||||
x: (needsScrolling ? -scrollOffset : 0) + textShift
|
||||
opacity: 1
|
||||
|
||||
onTextChanged: {
|
||||
scrollOffset = 0;
|
||||
textShift = 0;
|
||||
scrollAnimation.restart();
|
||||
textChangeAnimation.restart();
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
target: mediaText
|
||||
property: "scrollOffset"
|
||||
from: 0
|
||||
to: mediaText.implicitWidth - textContainer.width + 5
|
||||
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||
easing.type: Easing.Linear
|
||||
SequentialAnimation {
|
||||
id: scrollAnimation
|
||||
running: mediaText.needsScrolling && textContainer.visible
|
||||
loops: Animation.Infinite
|
||||
|
||||
PauseAnimation {
|
||||
duration: 2000
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
target: mediaText
|
||||
property: "scrollOffset"
|
||||
from: 0
|
||||
to: mediaText.implicitWidth - textContainer.width + 5
|
||||
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
|
||||
PauseAnimation {
|
||||
duration: 2000
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
target: mediaText
|
||||
property: "scrollOffset"
|
||||
to: 0
|
||||
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
}
|
||||
|
||||
PauseAnimation {
|
||||
duration: 2000
|
||||
}
|
||||
SequentialAnimation {
|
||||
id: textChangeAnimation
|
||||
|
||||
NumberAnimation {
|
||||
target: mediaText
|
||||
property: "scrollOffset"
|
||||
to: 0
|
||||
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||
easing.type: Easing.Linear
|
||||
ParallelAnimation {
|
||||
NumberAnimation {
|
||||
target: mediaText
|
||||
property: "opacity"
|
||||
from: 0.7
|
||||
to: 1
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
target: mediaText
|
||||
property: "textShift"
|
||||
from: 4
|
||||
to: 0
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,11 +435,7 @@ BasePill {
|
||||
anchors.fill: parent
|
||||
enabled: root.playerAvailable
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (activePlayer) {
|
||||
activePlayer.previous();
|
||||
}
|
||||
}
|
||||
onClicked: MprisController.previousOrRewind()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,15 @@ BasePill {
|
||||
PanelWindow {
|
||||
id: contextMenuWindow
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: contextMenuWindow
|
||||
blurX: menuContainer.x
|
||||
blurY: menuContainer.y
|
||||
blurWidth: contextMenuWindow.visible ? menuContainer.width : 0
|
||||
blurHeight: contextMenuWindow.visible ? menuContainer.height : 0
|
||||
blurRadius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "dms:notepad-context-menu"
|
||||
|
||||
property bool isVertical: false
|
||||
@@ -244,8 +253,8 @@ BasePill {
|
||||
height: Math.max(60, menuColumn.implicitHeight + Theme.spacingS * 2)
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: 1
|
||||
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
||||
|
||||
opacity: contextMenuWindow.visible ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modules.Notifications.Center
|
||||
import qs.Modules.Plugins
|
||||
import qs.Widgets
|
||||
|
||||
@@ -34,7 +35,49 @@ BasePill {
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: {
|
||||
SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
|
||||
onRightClicked: (rx, ry) => {
|
||||
const screen = root.parentScreen || Screen;
|
||||
if (!screen)
|
||||
return;
|
||||
const globalPos = root.visualContent.mapToItem(null, 0, 0);
|
||||
const isVertical = root.axis?.isVertical ?? false;
|
||||
const edge = root.axis?.edge ?? "top";
|
||||
const gap = Math.max(Theme.spacingXS, root.barSpacing ?? Theme.spacingXS);
|
||||
const barOffset = root.barThickness + root.barSpacing + gap;
|
||||
|
||||
let anchorX;
|
||||
let anchorY;
|
||||
let anchorEdge;
|
||||
if (isVertical) {
|
||||
anchorY = globalPos.y - (screen.y || 0) + root.visualContent.height / 2;
|
||||
if (edge === "left") {
|
||||
anchorX = barOffset;
|
||||
anchorEdge = "top";
|
||||
} else {
|
||||
anchorX = screen.width - barOffset;
|
||||
anchorEdge = "top";
|
||||
}
|
||||
} else {
|
||||
anchorX = globalPos.x - (screen.x || 0) + root.visualContent.width / 2;
|
||||
if (edge === "bottom") {
|
||||
anchorY = screen.height - barOffset;
|
||||
anchorEdge = "bottom";
|
||||
} else {
|
||||
anchorY = barOffset;
|
||||
anchorEdge = "top";
|
||||
}
|
||||
}
|
||||
|
||||
dndPopupLoader.active = true;
|
||||
const popup = dndPopupLoader.item;
|
||||
if (!popup)
|
||||
return;
|
||||
popup.showAt(anchorX, anchorY, screen, anchorEdge);
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: dndPopupLoader
|
||||
active: false
|
||||
sourceComponent: DndDurationPopup {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ BasePill {
|
||||
radius: Theme.cornerRadius
|
||||
color: {
|
||||
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";
|
||||
}
|
||||
@@ -526,7 +526,7 @@ BasePill {
|
||||
radius: Theme.cornerRadius
|
||||
color: {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ BasePill {
|
||||
enableCursor: false
|
||||
|
||||
property var parentWindow: null
|
||||
property var widgetData: null
|
||||
property string section: "right"
|
||||
property bool isAtBottom: false
|
||||
property bool isAutoHideBar: false
|
||||
property bool useOverflowPopup: !widgetData?.trayUseInlineExpansion
|
||||
readonly property var hiddenTrayIds: {
|
||||
const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || "";
|
||||
return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : [];
|
||||
@@ -40,6 +43,76 @@ BasePill {
|
||||
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);
|
||||
}
|
||||
|
||||
function toggleIconName() {
|
||||
const edge = root.axis?.edge;
|
||||
if (root.useOverflowPopup) {
|
||||
switch (edge) {
|
||||
case "left":
|
||||
return root.menuOpen ? "keyboard_arrow_left" : "keyboard_arrow_right";
|
||||
case "right":
|
||||
return root.menuOpen ? "keyboard_arrow_right" : "keyboard_arrow_left";
|
||||
case "bottom":
|
||||
return root.menuOpen ? "keyboard_arrow_down" : "keyboard_arrow_up";
|
||||
case "top":
|
||||
return root.menuOpen ? "keyboard_arrow_up" : "keyboard_arrow_down";
|
||||
}
|
||||
}
|
||||
|
||||
if (edge === "left" || edge === "right") {
|
||||
return root.menuOpen == (root.section !== "right") ? "keyboard_arrow_up" : "keyboard_arrow_down";
|
||||
}
|
||||
|
||||
return root.menuOpen != (root.section === "right") ? "keyboard_arrow_left" : "keyboard_arrow_right";
|
||||
}
|
||||
|
||||
// ! TODO - replace with either native dbus client (like plugins use) or just a DMS cli or something
|
||||
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");
|
||||
@@ -78,6 +151,13 @@ BasePill {
|
||||
item: item
|
||||
}))
|
||||
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||
readonly property bool reverseInlineHorizontal: !useOverflowPopup && !isVerticalOrientation && section === "right"
|
||||
readonly property bool reverseInlineVertical: !useOverflowPopup && isVerticalOrientation && section === "right"
|
||||
readonly property var displayedMainBarItems: reverseInlineHorizontal ? [...mainBarItems].reverse() : mainBarItems
|
||||
readonly property var displayedInlineExpandedItems: (reverseInlineHorizontal ? [...hiddenBarItems].reverse() : hiddenBarItems).map(item => ({
|
||||
key: getTrayItemKey(item),
|
||||
item: item
|
||||
}))
|
||||
|
||||
function moveTrayItemInFullOrder(visibleFromIndex, visibleToIndex) {
|
||||
if (visibleFromIndex === visibleToIndex || visibleFromIndex < 0 || visibleToIndex < 0)
|
||||
@@ -103,6 +183,7 @@ BasePill {
|
||||
property int dropTargetIndex: -1
|
||||
property bool suppressShiftAnimation: false
|
||||
readonly property bool hasHiddenItems: allTrayItems.length > mainBarItems.length
|
||||
readonly property bool inlineExpanded: hasHiddenItems && !useOverflowPopup && menuOpen
|
||||
visible: allTrayItems.length > 0
|
||||
opacity: allTrayItems.length > 0 ? 1 : 0
|
||||
|
||||
@@ -198,10 +279,11 @@ BasePill {
|
||||
id: rowComp
|
||||
Row {
|
||||
spacing: 0
|
||||
layoutDirection: root.reverseInlineHorizontal ? Qt.RightToLeft : Qt.LeftToRight
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.mainBarItems
|
||||
values: root.displayedMainBarItems
|
||||
objectProp: "key"
|
||||
}
|
||||
|
||||
@@ -209,29 +291,7 @@ BasePill {
|
||||
id: delegateRoot
|
||||
property var trayItem: modelData.item
|
||||
property string itemKey: modelData.key
|
||||
property string iconSource: {
|
||||
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 "";
|
||||
}
|
||||
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||
|
||||
width: root.trayItemSize
|
||||
height: root.barThickness
|
||||
@@ -371,7 +431,8 @@ BasePill {
|
||||
}
|
||||
if (!delegateRoot.trayItem.hasMenu)
|
||||
return;
|
||||
root.menuOpen = false;
|
||||
if (root.useOverflowPopup)
|
||||
root.menuOpen = false;
|
||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
|
||||
@@ -380,8 +441,8 @@ BasePill {
|
||||
const distance = Math.abs(mouse.x - dragHandler.dragStartPos.x);
|
||||
if (distance > 5) {
|
||||
dragHandler.dragging = true;
|
||||
root.draggedIndex = index;
|
||||
root.dropTargetIndex = index;
|
||||
root.draggedIndex = root.reverseInlineHorizontal ? (root.mainBarItems.length - 1 - index) : index;
|
||||
root.dropTargetIndex = root.draggedIndex;
|
||||
}
|
||||
}
|
||||
if (!dragHandler.dragging)
|
||||
@@ -391,7 +452,8 @@ BasePill {
|
||||
dragHandler.dragAxisOffset = axisOffset;
|
||||
const itemSize = root.trayItemSize;
|
||||
const slotOffset = Math.round(axisOffset / itemSize);
|
||||
const newTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||
const visualTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||
const newTargetIndex = root.reverseInlineHorizontal ? (root.mainBarItems.length - 1 - visualTargetIndex) : visualTargetIndex;
|
||||
if (newTargetIndex !== root.dropTargetIndex) {
|
||||
root.dropTargetIndex = newTargetIndex;
|
||||
}
|
||||
@@ -407,7 +469,8 @@ BasePill {
|
||||
root.callContextMenuFallback(delegateRoot.trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
||||
return;
|
||||
}
|
||||
root.menuOpen = false;
|
||||
if (root.useOverflowPopup)
|
||||
root.menuOpen = false;
|
||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
}
|
||||
@@ -429,7 +492,7 @@ BasePill {
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: root.menuOpen ? "expand_less" : "expand_more"
|
||||
name: root.toggleIconName()
|
||||
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
@@ -451,6 +514,301 @@ BasePill {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.displayedInlineExpandedItems
|
||||
objectProp: "key"
|
||||
}
|
||||
|
||||
delegate: inlineExpandedTrayItemDelegate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: inlineExpandedTrayItemDelegate
|
||||
|
||||
Item {
|
||||
property var trayItem: modelData.item
|
||||
property string itemKey: modelData.key
|
||||
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||
|
||||
width: root.isVerticalOrientation ? root.barThickness : (root.inlineExpanded ? root.trayItemSize : 0)
|
||||
height: root.isVerticalOrientation ? (root.inlineExpanded ? root.trayItemSize : 0) : root.barThickness
|
||||
visible: width > 0 || height > 0
|
||||
|
||||
Behavior on width {
|
||||
enabled: !root.isVerticalOrientation
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on height {
|
||||
enabled: root.isVerticalOrientation
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: inlineVisualContent
|
||||
width: root.trayItemSize
|
||||
height: root.trayItemSize
|
||||
x: root.isVerticalOrientation ? Math.round((parent.width - width) / 2) : (root.reverseInlineHorizontal ? parent.width - width : 0)
|
||||
y: root.isVerticalOrientation ? (root.reverseInlineVertical ? parent.height - height : 0) : Math.round((parent.height - height) / 2)
|
||||
radius: Theme.cornerRadius
|
||||
color: inlineTrayItemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
opacity: root.inlineExpanded ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: inlineIconImg
|
||||
anchors.centerIn: parent
|
||||
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
source: iconSource
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
mipmap: true
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !inlineIconImg.visible
|
||||
text: {
|
||||
const itemId = trayItem?.id || "";
|
||||
if (!itemId)
|
||||
return "?";
|
||||
return itemId.charAt(0).toUpperCase();
|
||||
}
|
||||
font.pixelSize: 10
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
|
||||
DankRipple {
|
||||
id: inlineItemRipple
|
||||
cornerRadius: Theme.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: inlineTrayItemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: root.inlineExpanded
|
||||
|
||||
onPressed: mouse => {
|
||||
const pos = mapToItem(inlineVisualContent, mouse.x, mouse.y);
|
||||
inlineItemRipple.trigger(pos.x, pos.y);
|
||||
}
|
||||
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
root.activateInlineTrayItem(trayItem, inlineVisualContent);
|
||||
return;
|
||||
}
|
||||
if (mouse.button !== Qt.RightButton)
|
||||
return;
|
||||
root.openInlineTrayContextMenu(trayItem, inlineTrayItemArea, mouse, inlineVisualContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: verticalMainTrayItemDelegate
|
||||
|
||||
Item {
|
||||
property var trayItem: modelData.item
|
||||
property string itemKey: modelData.key
|
||||
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||
|
||||
width: root.barThickness
|
||||
height: root.trayItemSize
|
||||
z: dragHandler.dragging ? 100 : 0
|
||||
|
||||
property real shiftOffset: {
|
||||
if (root.draggedIndex < 0)
|
||||
return 0;
|
||||
if (index === root.draggedIndex)
|
||||
return 0;
|
||||
const dragIdx = root.draggedIndex;
|
||||
const dropIdx = root.dropTargetIndex;
|
||||
const shiftAmount = root.trayItemSize;
|
||||
if (dropIdx < 0)
|
||||
return 0;
|
||||
if (dragIdx < dropIdx && index > dragIdx && index <= dropIdx)
|
||||
return -shiftAmount;
|
||||
if (dragIdx > dropIdx && index >= dropIdx && index < dragIdx)
|
||||
return shiftAmount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
transform: Translate {
|
||||
y: shiftOffset
|
||||
Behavior on y {
|
||||
enabled: !root.suppressShiftAnimation
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: dragHandler
|
||||
anchors.fill: parent
|
||||
property bool dragging: false
|
||||
property point dragStartPos: Qt.point(0, 0)
|
||||
property real dragAxisOffset: 0
|
||||
property bool longPressing: false
|
||||
|
||||
Timer {
|
||||
id: longPressTimer
|
||||
interval: 400
|
||||
repeat: false
|
||||
onTriggered: dragHandler.longPressing = true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: visualContent
|
||||
width: root.trayItemSize
|
||||
height: root.trayItemSize
|
||||
anchors.centerIn: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: trayItemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
border.width: dragHandler.dragging ? 2 : 0
|
||||
border.color: Theme.primary
|
||||
opacity: dragHandler.dragging ? 0.8 : 1.0
|
||||
|
||||
transform: Translate {
|
||||
y: dragHandler.dragging ? dragHandler.dragAxisOffset : 0
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: iconImg
|
||||
anchors.centerIn: parent
|
||||
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
source: iconSource
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
mipmap: true
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !iconImg.visible
|
||||
text: {
|
||||
const itemId = trayItem?.id || "";
|
||||
if (!itemId)
|
||||
return "?";
|
||||
return itemId.charAt(0).toUpperCase();
|
||||
}
|
||||
font.pixelSize: 10
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
|
||||
DankRipple {
|
||||
id: itemRipple
|
||||
cornerRadius: Theme.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: trayItemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: dragHandler.longPressing ? Qt.DragMoveCursor : Qt.PointingHandCursor
|
||||
|
||||
onPressed: mouse => {
|
||||
const pos = mapToItem(visualContent, mouse.x, mouse.y);
|
||||
itemRipple.trigger(pos.x, pos.y);
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
dragHandler.dragStartPos = Qt.point(mouse.x, mouse.y);
|
||||
longPressTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
longPressTimer.stop();
|
||||
const wasDragging = dragHandler.dragging;
|
||||
const didReorder = wasDragging && root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex;
|
||||
|
||||
if (didReorder) {
|
||||
root.suppressShiftAnimation = true;
|
||||
root.moveTrayItemInFullOrder(root.draggedIndex, root.dropTargetIndex);
|
||||
Qt.callLater(() => root.suppressShiftAnimation = false);
|
||||
}
|
||||
|
||||
dragHandler.longPressing = false;
|
||||
dragHandler.dragging = false;
|
||||
dragHandler.dragAxisOffset = 0;
|
||||
root.draggedIndex = -1;
|
||||
root.dropTargetIndex = -1;
|
||||
|
||||
if (wasDragging || mouse.button !== Qt.LeftButton)
|
||||
return;
|
||||
|
||||
if (!trayItem)
|
||||
return;
|
||||
if (!trayItem.onlyMenu) {
|
||||
trayItem.activate();
|
||||
return;
|
||||
}
|
||||
if (!trayItem.hasMenu)
|
||||
return;
|
||||
if (root.useOverflowPopup)
|
||||
root.menuOpen = false;
|
||||
root.showForTrayItem(trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (dragHandler.longPressing && !dragHandler.dragging) {
|
||||
const distance = Math.abs(mouse.y - dragHandler.dragStartPos.y);
|
||||
if (distance > 5) {
|
||||
dragHandler.dragging = true;
|
||||
root.draggedIndex = index;
|
||||
root.dropTargetIndex = root.draggedIndex;
|
||||
}
|
||||
}
|
||||
if (!dragHandler.dragging)
|
||||
return;
|
||||
|
||||
const axisOffset = mouse.y - dragHandler.dragStartPos.y;
|
||||
dragHandler.dragAxisOffset = axisOffset;
|
||||
const itemSize = root.trayItemSize;
|
||||
const slotOffset = Math.round(axisOffset / itemSize);
|
||||
const newTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||
if (newTargetIndex !== root.dropTargetIndex) {
|
||||
root.dropTargetIndex = newTargetIndex;
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: mouse => {
|
||||
if (dragHandler.dragging)
|
||||
return;
|
||||
if (mouse.button !== Qt.RightButton)
|
||||
return;
|
||||
root.openInlineTrayContextMenu(trayItem, trayItemArea, mouse, visualContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,219 +817,23 @@ BasePill {
|
||||
Column {
|
||||
spacing: 0
|
||||
|
||||
// Column lacks layoutDirection, so we use four repeaters with mutually exclusive models to control whether main items or expanded items appear above/ below the toggle button.
|
||||
// When reverseInlineVertical is true the first and third repeaters are empty and the second and fourth are active, and vice-versa.
|
||||
// Because items are swapped between repeaters rather than reversed within a single list, vertical drag-and-drop indices don't need remapping (unlike the horizontal RightToLeft case).
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.mainBarItems
|
||||
values: root.reverseInlineVertical ? [] : root.displayedMainBarItems
|
||||
objectProp: "key"
|
||||
}
|
||||
delegate: verticalMainTrayItemDelegate
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: delegateRoot
|
||||
property var trayItem: modelData.item
|
||||
property string itemKey: modelData.key
|
||||
property string iconSource: {
|
||||
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 "";
|
||||
}
|
||||
|
||||
width: root.barThickness
|
||||
height: root.trayItemSize
|
||||
z: dragHandler.dragging ? 100 : 0
|
||||
|
||||
property real shiftOffset: {
|
||||
if (root.draggedIndex < 0)
|
||||
return 0;
|
||||
if (index === root.draggedIndex)
|
||||
return 0;
|
||||
const dragIdx = root.draggedIndex;
|
||||
const dropIdx = root.dropTargetIndex;
|
||||
const shiftAmount = root.trayItemSize;
|
||||
if (dropIdx < 0)
|
||||
return 0;
|
||||
if (dragIdx < dropIdx && index > dragIdx && index <= dropIdx)
|
||||
return -shiftAmount;
|
||||
if (dragIdx > dropIdx && index >= dropIdx && index < dragIdx)
|
||||
return shiftAmount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
transform: Translate {
|
||||
y: delegateRoot.shiftOffset
|
||||
Behavior on y {
|
||||
enabled: !root.suppressShiftAnimation
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: dragHandler
|
||||
anchors.fill: parent
|
||||
property bool dragging: false
|
||||
property point dragStartPos: Qt.point(0, 0)
|
||||
property real dragAxisOffset: 0
|
||||
property bool longPressing: false
|
||||
|
||||
Timer {
|
||||
id: longPressTimer
|
||||
interval: 400
|
||||
repeat: false
|
||||
onTriggered: dragHandler.longPressing = true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: visualContent
|
||||
width: root.trayItemSize
|
||||
height: root.trayItemSize
|
||||
anchors.centerIn: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: trayItemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
border.width: dragHandler.dragging ? 2 : 0
|
||||
border.color: Theme.primary
|
||||
opacity: dragHandler.dragging ? 0.8 : 1.0
|
||||
|
||||
transform: Translate {
|
||||
y: dragHandler.dragging ? dragHandler.dragAxisOffset : 0
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: iconImg
|
||||
anchors.centerIn: parent
|
||||
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
source: delegateRoot.iconSource
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
mipmap: true
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !iconImg.visible
|
||||
text: {
|
||||
const itemId = trayItem?.id || "";
|
||||
if (!itemId)
|
||||
return "?";
|
||||
return itemId.charAt(0).toUpperCase();
|
||||
}
|
||||
font.pixelSize: 10
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
|
||||
DankRipple {
|
||||
id: itemRipple
|
||||
cornerRadius: Theme.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: trayItemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: dragHandler.longPressing ? Qt.DragMoveCursor : Qt.PointingHandCursor
|
||||
|
||||
onPressed: mouse => {
|
||||
const pos = mapToItem(visualContent, mouse.x, mouse.y);
|
||||
itemRipple.trigger(pos.x, pos.y);
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
dragHandler.dragStartPos = Qt.point(mouse.x, mouse.y);
|
||||
longPressTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
longPressTimer.stop();
|
||||
const wasDragging = dragHandler.dragging;
|
||||
const didReorder = wasDragging && root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex;
|
||||
|
||||
if (didReorder) {
|
||||
root.suppressShiftAnimation = true;
|
||||
root.moveTrayItemInFullOrder(root.draggedIndex, root.dropTargetIndex);
|
||||
Qt.callLater(() => root.suppressShiftAnimation = false);
|
||||
}
|
||||
|
||||
dragHandler.longPressing = false;
|
||||
dragHandler.dragging = false;
|
||||
dragHandler.dragAxisOffset = 0;
|
||||
root.draggedIndex = -1;
|
||||
root.dropTargetIndex = -1;
|
||||
|
||||
if (wasDragging || mouse.button !== Qt.LeftButton)
|
||||
return;
|
||||
|
||||
if (!delegateRoot.trayItem)
|
||||
return;
|
||||
if (!delegateRoot.trayItem.onlyMenu) {
|
||||
delegateRoot.trayItem.activate();
|
||||
return;
|
||||
}
|
||||
if (!delegateRoot.trayItem.hasMenu)
|
||||
return;
|
||||
root.menuOpen = false;
|
||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (dragHandler.longPressing && !dragHandler.dragging) {
|
||||
const distance = Math.abs(mouse.y - dragHandler.dragStartPos.y);
|
||||
if (distance > 5) {
|
||||
dragHandler.dragging = true;
|
||||
root.draggedIndex = index;
|
||||
root.dropTargetIndex = index;
|
||||
}
|
||||
}
|
||||
if (!dragHandler.dragging)
|
||||
return;
|
||||
|
||||
const axisOffset = mouse.y - dragHandler.dragStartPos.y;
|
||||
dragHandler.dragAxisOffset = axisOffset;
|
||||
const itemSize = root.trayItemSize;
|
||||
const slotOffset = Math.round(axisOffset / itemSize);
|
||||
const newTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||
if (newTargetIndex !== root.dropTargetIndex) {
|
||||
root.dropTargetIndex = newTargetIndex;
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: mouse => {
|
||||
if (dragHandler.dragging)
|
||||
return;
|
||||
if (mouse.button !== Qt.RightButton)
|
||||
return;
|
||||
if (!delegateRoot.trayItem?.hasMenu) {
|
||||
const gp = trayItemArea.mapToGlobal(mouse.x, mouse.y);
|
||||
root.callContextMenuFallback(delegateRoot.trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
||||
return;
|
||||
}
|
||||
root.menuOpen = false;
|
||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
}
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.reverseInlineVertical ? root.displayedInlineExpandedItems : []
|
||||
objectProp: "key"
|
||||
}
|
||||
delegate: inlineExpandedTrayItemDelegate
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -689,14 +851,7 @@ BasePill {
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: {
|
||||
const edge = root.axis?.edge;
|
||||
if (edge === "left") {
|
||||
return root.menuOpen ? "chevron_left" : "chevron_right";
|
||||
} else {
|
||||
return root.menuOpen ? "chevron_right" : "chevron_left";
|
||||
}
|
||||
}
|
||||
name: root.toggleIconName()
|
||||
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
@@ -718,6 +873,22 @@ BasePill {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.reverseInlineVertical ? [] : root.displayedInlineExpandedItems
|
||||
objectProp: "key"
|
||||
}
|
||||
delegate: inlineExpandedTrayItemDelegate
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: root.reverseInlineVertical ? root.displayedMainBarItems : []
|
||||
objectProp: "key"
|
||||
}
|
||||
delegate: verticalMainTrayItemDelegate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,7 +904,7 @@ BasePill {
|
||||
blurRadius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
visible: root.menuOpen
|
||||
visible: root.useOverflowPopup && root.menuOpen
|
||||
screen: root.parentScreen
|
||||
WlrLayershell.layer: WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
@@ -749,13 +920,14 @@ BasePill {
|
||||
|
||||
HyprlandFocusGrab {
|
||||
windows: [overflowMenu]
|
||||
active: CompositorService.useHyprlandFocusGrab && root.menuOpen
|
||||
active: CompositorService.useHyprlandFocusGrab && root.useOverflowPopup && root.menuOpen
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: PopoutManager
|
||||
function onPopoutOpening() {
|
||||
root.menuOpen = false;
|
||||
if (root.useOverflowPopup)
|
||||
root.menuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,30 +1193,7 @@ BasePill {
|
||||
|
||||
delegate: Rectangle {
|
||||
property var trayItem: modelData
|
||||
property string iconSource: {
|
||||
let icon = 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 "";
|
||||
}
|
||||
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||
|
||||
width: root.trayItemSize + 4
|
||||
height: root.trayItemSize + 4
|
||||
@@ -1313,7 +1462,8 @@ BasePill {
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
updatePosition();
|
||||
root.menuOpen = false;
|
||||
if (root.useOverflowPopup)
|
||||
root.menuOpen = false;
|
||||
PopoutManager.closeAllPopouts();
|
||||
ModalManager.closeAllModalsExcept(null);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ DankPopout {
|
||||
if (currentPlayer && currentPlayer !== player && currentPlayer.canPause) {
|
||||
currentPlayer.pause();
|
||||
}
|
||||
MprisController.activePlayer = player;
|
||||
MprisController.setActivePlayer(player);
|
||||
root.__hideDropdowns();
|
||||
}
|
||||
onDeviceSelected: device => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
@@ -25,7 +24,7 @@ Item {
|
||||
}
|
||||
property bool usePlayerVolume: activePlayer && activePlayer.volumeSupported && !__isChromeBrowser
|
||||
property real currentVolume: usePlayerVolume ? activePlayer.volume : (AudioService.sink?.audio?.volume ?? 0)
|
||||
property bool volumeAvailable: (activePlayer && activePlayer.volumeSupported && !__isChromeBrowser) || (AudioService.sink && AudioService.sink.audio)
|
||||
property bool volumeAvailable: !!((activePlayer && activePlayer.volumeSupported && !__isChromeBrowser) || (AudioService.sink && AudioService.sink.audio))
|
||||
property var availableDevices: {
|
||||
const hidden = SessionData.hiddenOutputDeviceNames ?? [];
|
||||
return Pipewire.nodes.values.filter(node => {
|
||||
@@ -336,8 +335,8 @@ Item {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (modelData) {
|
||||
Pipewire.preferredDefaultAudioSink = modelData;
|
||||
if (modelData && modelData.name) {
|
||||
AudioService.setDefaultSinkByName(modelData.name);
|
||||
root.deviceSelected(modelData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ Item {
|
||||
const id = activePlayer.identity.toLowerCase();
|
||||
return id.includes("chrome") || id.includes("chromium");
|
||||
}
|
||||
readonly property bool volumeAvailable: (activePlayer && activePlayer.volumeSupported && !__isChromeBrowser) || (AudioService.sink && AudioService.sink.audio)
|
||||
readonly property bool volumeAvailable: !!((activePlayer && activePlayer.volumeSupported && !__isChromeBrowser) || (AudioService.sink && AudioService.sink.audio))
|
||||
readonly property bool usePlayerVolume: activePlayer && activePlayer.volumeSupported && !__isChromeBrowser
|
||||
readonly property real currentVolume: usePlayerVolume ? activePlayer.volume : (AudioService.sink?.audio?.volume ?? 0)
|
||||
|
||||
@@ -487,17 +487,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (!activePlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activePlayer.position > 8 && activePlayer.canSeek) {
|
||||
activePlayer.position = 0;
|
||||
} else {
|
||||
activePlayer.previous();
|
||||
}
|
||||
}
|
||||
onClicked: MprisController.previousOrRewind()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,14 +145,7 @@ Card {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (!activePlayer) return
|
||||
if (activePlayer.position > 8 && activePlayer.canSeek) {
|
||||
activePlayer.position = 0
|
||||
} else {
|
||||
activePlayer.previous()
|
||||
}
|
||||
}
|
||||
onClicked: MprisController.previousOrRewind()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: windowArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: windowArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
@@ -320,7 +320,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: actionArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: actionArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
@@ -395,7 +395,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: pinArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: pinArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
@@ -468,7 +468,7 @@ PanelWindow {
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: nvidiaArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
color: nvidiaArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -147,6 +147,13 @@ Scope {
|
||||
}
|
||||
}
|
||||
|
||||
Pam {
|
||||
id: sharedPam
|
||||
lockSecured: root.shouldLock
|
||||
buffer: root.sharedPasswordBuffer
|
||||
onUnlockRequested: root.unlock()
|
||||
}
|
||||
|
||||
WlSessionLock {
|
||||
id: sessionLock
|
||||
|
||||
@@ -170,6 +177,7 @@ Scope {
|
||||
anchors.fill: parent
|
||||
visible: lockSurface.isActiveScreen
|
||||
lock: sessionLock
|
||||
pam: sharedPam
|
||||
sharedPasswordBuffer: root.sharedPasswordBuffer
|
||||
screenName: lockSurface.currentScreenName
|
||||
isLocked: shouldLock
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user