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

Compare commits

..

1 Commits

Author SHA1 Message Date
bbedward
cbfb9f6dd0 blur: add blur support with ext-bg-effect 2026-03-25 15:16:54 -04:00
363 changed files with 32165 additions and 48504 deletions

View File

@@ -1,4 +1,4 @@
name: Nix flake and NixOS tests name: Check nix flake
on: on:
pull_request: pull_request:
@@ -9,7 +9,6 @@ on:
jobs: jobs:
check-flake: check-flake:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120
steps: steps:
- name: Checkout - name: Checkout
@@ -19,25 +18,6 @@ jobs:
- name: Install Nix - name: Install Nix
uses: cachix/install-nix-action@v31 uses: cachix/install-nix-action@v31
with:
enable_kvm: true
extra_nix_config: |
system-features = nixos-test benchmark big-parallel kvm
- name: Check the flake - name: Check the flake
run: nix flake check -L run: nix flake check
- name: Run NixOS module test
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
- name: Run NixOS service start test
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
- name: Run greeter niri test
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
- name: Run home-manager module test
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
- name: Run niri home-manager module test
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L

View File

@@ -243,7 +243,7 @@ jobs:
fi fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ppa-upload.sh uploads to questing + resolute when series is omitted # ppa-upload.sh uploads to questing + resolute when series is omitted
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" "" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
echo "::error::Upload failed for $PKG" echo "::error::Upload failed for $PKG"
exit 1 exit 1
fi fi

View File

@@ -20,11 +20,3 @@ repos:
language: system language: system
files: ^core/.*\.(go|mod|sum)$ files: ^core/.*\.(go|mod|sum)$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: no-console-in-qml
name: no console.* in QML (use Log service)
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
language: system
files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)

View File

@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers) [![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE) [![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases) [![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/) [![AUR version](https://img.shields.io/aur/version/dms-shell-bin?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://aur.archlinux.org/packages/dms-shell-bin)
[![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git) [![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux) [![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux)

View File

@@ -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. Command-line interface and daemon for shell management and system control.
**dankinstall** **dankinstall**
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. Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
## System Integration ## System Integration
@@ -147,50 +147,10 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
## Installation via dankinstall ## Installation via dankinstall
**Interactive (TUI):**
```bash ```bash
curl -fsSL https://install.danklinux.com | sh 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 ## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives) Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)

View File

@@ -3,152 +3,20 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui" "github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
) )
var Version = "dev" 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() { func main() {
if os.Getuid() == 0 { if os.Getuid() == 0 {
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root") fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
os.Exit(1) 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() fileLogger, err := log.NewFileLogger()
if err != nil { if err != nil {
fmt.Printf("Warning: Failed to create log file: %v\n", err) fmt.Printf("Warning: Failed to create log file: %v\n", err)
@@ -170,50 +38,18 @@ func runTUI() error {
if fileLogger != nil { if fileLogger != nil {
fileLogger.StartListening(model.GetLogChan()) 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()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
if logFilePath != "" { if logFilePath != "" {
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath) fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
} }
return fmt.Errorf("error running program: %w", err) os.Exit(1)
} }
if logFilePath != "" { if logFilePath != "" {
fmt.Printf("\nFull logs are available at: %s\n", 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
}
} }

View File

@@ -1,77 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
)
var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage DMS authentication sync",
Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen",
}
var authSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS authentication configuration",
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal")
if term {
if err := syncAuthInTerminal(yes); err != nil {
log.Fatalf("Error launching auth sync in terminal: %v", err)
}
return
}
if err := syncAuth(yes); err != nil {
log.Fatalf("Error syncing authentication: %v", err)
}
},
}
func init() {
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
}
func syncAuth(nonInteractive bool) error {
if !nonInteractive {
fmt.Println("=== DMS Authentication Sync ===")
fmt.Println()
}
logFunc := func(msg string) {
fmt.Println(msg)
}
if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil {
return err
}
if !nonInteractive {
fmt.Println("\n=== Authentication Sync Complete ===")
fmt.Println("\nAuthentication changes have been applied.")
}
return nil
}
func syncAuthInTerminal(nonInteractive bool) error {
syncFlags := make([]string, 0, 1)
if nonInteractive {
syncFlags = append(syncFlags, "--yes")
}
shellSyncCmd := "dms auth sync"
if len(syncFlags) > 0 {
shellSyncCmd += " " + strings.Join(syncFlags, " ")
}
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
return runCommandInTerminal(shellCmd)
}

View File

@@ -1,40 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/spf13/cobra"
)
var blurCmd = &cobra.Command{
Use: "blur",
Short: "Background blur utilities",
}
var blurCheckCmd = &cobra.Command{
Use: "check",
Short: "Check if the compositor supports background blur (ext-background-effect-v1)",
Args: cobra.NoArgs,
Run: runBlurCheck,
}
func init() {
blurCmd.AddCommand(blurCheckCmd)
}
func runBlurCheck(cmd *cobra.Command, args []string) {
supported, err := blur.ProbeSupport()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
switch supported {
case true:
fmt.Println("supported")
default:
fmt.Println("unsupported")
}
}

View File

@@ -236,7 +236,6 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
defer ddc.Close() defer ddc.Close()
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil { if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
ddc.WaitPending()
fmt.Printf("Set %s to %d%%\n", deviceID, percent) fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return return
} }

View File

@@ -37,9 +37,6 @@ Output format flags (mutually exclusive, default: --hex):
--cmyk - CMYK values (C% M% Y% K%) --cmyk - CMYK values (C% M% Y% K%)
--json - JSON with all formats --json - JSON with all formats
Optional:
--raw - Removes ANSI escape codes and background colors. Use this when piping to other commands
Examples: Examples:
dms color pick # Pick color, output as hex dms color pick # Pick color, output as hex
dms color pick --rgb # Output as RGB dms color pick --rgb # Output as RGB
@@ -56,7 +53,6 @@ func init() {
colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)") colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)")
colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)") colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)")
colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON") colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON")
colorPickCmd.Flags().Bool("raw", false, "Removes ANSI escape codes and background colors. Use this when piping to other commands")
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template") colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard") colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase") colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
@@ -117,15 +113,7 @@ func runColorPick(cmd *cobra.Command, args []string) {
if jsonOutput { if jsonOutput {
fmt.Println(output) fmt.Println(output)
return } else if color.IsDark() {
}
if raw, _ := cmd.Flags().GetBool("raw"); raw {
fmt.Printf("%s\n", output)
return
}
if color.IsDark() {
fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output) fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output)
} else { } else {
fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output) fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output)

View File

@@ -26,17 +26,6 @@ var runCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon") daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session") session, _ := cmd.Flags().GetBool("session")
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
}
}
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
}
}
log.ApplyEnvOverrides()
if daemon { if daemon {
runShellDaemon(session) runShellDaemon(session)
} else { } else {
@@ -536,8 +525,5 @@ func getCommonCommands() []*cobra.Command {
configCmd, configCmd,
dlCmd, dlCmd,
randrCmd, randrCmd,
blurCmd,
trashCmd,
systemCmd,
} }
} }

View File

@@ -11,7 +11,6 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
@@ -83,7 +82,7 @@ func (ds *DoctorStatus) OKCount() int {
} }
var ( var (
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`) quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`) niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`) swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
@@ -91,7 +90,6 @@ var (
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`) wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`) labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`) mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
) )
var doctorCmd = &cobra.Command{ var doctorCmd = &cobra.Command{
@@ -470,7 +468,6 @@ func checkWindowManagers() []checkResult {
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}}, {"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}}, {"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}}, {"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
} }
var results []checkResult var results []checkResult
@@ -503,7 +500,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{ results = append(results, checkResult{
catCompositor, "Compositor", statusError, catCompositor, "Compositor", statusError,
"No supported Wayland compositor found", "No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm", "Install Hyprland, niri, Sway, River, or Wayfire",
doctorDocsURL + "#compositor-checks", doctorDocsURL + "#compositor-checks",
}) })
} }
@@ -512,24 +509,9 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"}) results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
} }
results = append(results, checkCompositorBlurSupport())
return results return results
} }
func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport()
if err != nil {
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
}
if supported {
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string { func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput() output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 { if err != nil && len(output) == 0 {
@@ -553,8 +535,6 @@ func detectRunningWM() string {
return "Hyprland" return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "": case os.Getenv("NIRI_SOCKET") != "":
return "niri" return "niri"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "": case os.Getenv("XDG_CURRENT_DESKTOP") != "":
return os.Getenv("XDG_CURRENT_DESKTOP") return os.Getenv("XDG_CURRENT_DESKTOP")
} }
@@ -573,7 +553,6 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
qmlContent := ` qmlContent := `
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Wayland
ShellRoot { ShellRoot {
id: root id: root
@@ -582,7 +561,6 @@ ShellRoot {
property bool idleMonitorAvailable: false property bool idleMonitorAvailable: false
property bool idleInhibitorAvailable: false property bool idleInhibitorAvailable: false
property bool shortcutInhibitorAvailable: false property bool shortcutInhibitorAvailable: false
property bool backgroundBlurAvailable: false
Timer { Timer {
interval: 50 interval: 50
@@ -600,18 +578,16 @@ ShellRoot {
try { try {
var testItem = Qt.createQmlObject( var testItem = Qt.createQmlObject(
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' + 'import Quickshell.Wayland; import QtQuick; QtObject { ' +
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' + 'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' + 'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' + 'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
'}', '}',
root root
) )
root.idleMonitorAvailable = testItem.hasIdleMonitor root.idleMonitorAvailable = testItem.hasIdleMonitor
root.idleInhibitorAvailable = testItem.hasIdleInhibitor root.idleInhibitorAvailable = testItem.hasIdleInhibitor
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
testItem.destroy() testItem.destroy()
} catch (e) {} } catch (e) {}
@@ -620,8 +596,6 @@ ShellRoot {
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE") console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE") console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)]) Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
} }
} }
@@ -642,7 +616,6 @@ ShellRoot {
{"IdleMonitor", "Idle detection"}, {"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"}, {"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"}, {"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
} }
var results []checkResult var results []checkResult
@@ -847,14 +820,10 @@ func checkOptionalDependencies() []checkResult {
results = append(results, checkImageFormatPlugins()...) results = append(results, checkImageFormatPlugins()...)
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"} terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
terminals = slices.DeleteFunc(terminals, func(t string) bool { if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
return !utils.CommandExists(t) results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
})
if len(terminals) > 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
} else { } else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL}) results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
} }
networkResult, err := network.DetectNetworkStack() networkResult, err := network.DetectNetworkStack()

View File

@@ -4,7 +4,6 @@ package main
import ( import (
"bufio" "bufio"
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -16,7 +15,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -111,37 +109,16 @@ func updateArchLinux() error {
} }
var packageName string var packageName string
var isAUR bool if isArchPackageInstalled("dms-shell-bin") {
if isArchPackageInstalled("dms-shell") { packageName = "dms-shell-bin"
packageName = "dms-shell"
} else if isArchPackageInstalled("dms-shell-git") { } else if isArchPackageInstalled("dms-shell-git") {
packageName = "dms-shell-git" packageName = "dms-shell-git"
isAUR = true
} else if isArchPackageInstalled("dms-shell-bin") {
packageName = "dms-shell-bin"
isAUR = true
} else { } else {
fmt.Println("Info: No dms-shell package found.") fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
fmt.Println("Info: Falling back to git-based update method...") fmt.Println("Info: Falling back to git-based update method...")
return updateOtherDistros() 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 helper string
var updateCmd *exec.Cmd var updateCmd *exec.Cmd
@@ -477,7 +454,11 @@ func updateDMSBinary() error {
fmt.Printf("Installing to %s...\n", currentPath) fmt.Printf("Installing to %s...\n", currentPath)
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil { replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
replaceCmd.Stdin = os.Stdin
replaceCmd.Stdout = os.Stdout
replaceCmd.Stderr = os.Stderr
if err := replaceCmd.Run(); err != nil {
return fmt.Errorf("failed to replace binary: %w", err) return fmt.Errorf("failed to replace binary: %w", err)
} }

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"bufio" "bufio"
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -14,8 +13,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/text/cases" "golang.org/x/text/cases"
@@ -28,16 +25,11 @@ var greeterCmd = &cobra.Command{
Long: "Manage DMS greeter (greetd)", Long: "Manage DMS greeter (greetd)",
} }
var (
greeterConfigSyncFn = greeter.SyncDMSConfigs
sharedAuthSyncFn = sharedpam.SyncAuthConfig
)
var greeterInstallCmd = &cobra.Command{ var greeterInstallCmd = &cobra.Command{
Use: "install", Use: "install",
Short: "Install and configure DMS greeter", Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface", Long: "Install greetd and configure it to use DMS as the greeter interface",
PreRunE: preRunPrivileged, PreRunE: requireMutableSystemCommand,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -59,10 +51,9 @@ var greeterInstallCmd = &cobra.Command{
} }
var greeterSyncCmd = &cobra.Command{ var greeterSyncCmd = &cobra.Command{
Use: "sync", Use: "sync",
Short: "Sync DMS theme and settings with greeter", Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth") auth, _ := cmd.Flags().GetBool("auth")
@@ -91,7 +82,7 @@ var greeterEnableCmd = &cobra.Command{
Use: "enable", Use: "enable",
Short: "Enable DMS greeter in greetd config", Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter", Long: "Configure greetd to use DMS as the greeter",
PreRunE: preRunPrivileged, PreRunE: requireMutableSystemCommand,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -127,7 +118,7 @@ var greeterUninstallCmd = &cobra.Command{
Use: "uninstall", Use: "uninstall",
Short: "Remove DMS greeter configuration and restore previous display manager", Short: "Remove DMS greeter configuration and restore previous display manager",
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state", Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
PreRunE: preRunPrivileged, PreRunE: requireMutableSystemCommand,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
@@ -157,16 +148,6 @@ func init() {
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
} }
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
return err
}
if beforeAuth != nil {
beforeAuth()
}
return sharedAuthSyncFn(logFunc, "", options)
}
func installGreeter(nonInteractive bool) error { func installGreeter(nonInteractive bool) error {
fmt.Println("=== DMS Greeter Installation ===") fmt.Println("=== DMS Greeter Installation ===")
@@ -262,9 +243,7 @@ func installGreeter(nonInteractive bool) error {
} }
fmt.Println("\nSynchronizing DMS configurations...") fmt.Println("\nSynchronizing DMS configurations...")
if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() { if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, "", false); err != nil {
fmt.Println("\nConfiguring authentication...")
}); err != nil {
return err return err
} }
@@ -299,7 +278,7 @@ func uninstallGreeter(nonInteractive bool) error {
} }
if !nonInteractive { if !nonInteractive {
fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS-managed greeter auth block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ") fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS PAM managed block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ")
var response string var response string
fmt.Scanln(&response) fmt.Scanln(&response)
if strings.ToLower(strings.TrimSpace(response)) != "y" { if strings.ToLower(strings.TrimSpace(response)) != "y" {
@@ -309,14 +288,17 @@ func uninstallGreeter(nonInteractive bool) error {
} }
fmt.Println("\nDisabling greetd...") fmt.Println("\nDisabling greetd...")
if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil { disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd")
disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err) fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
} else { } else {
fmt.Println(" ✓ greetd disabled") fmt.Println(" ✓ greetd disabled")
} }
fmt.Println("\nRemoving DMS authentication configuration...") fmt.Println("\nRemoving DMS PAM configuration...")
if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil { if err := greeter.RemoveGreeterPamManagedBlock(logFunc, ""); err != nil {
fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err) fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err)
} }
@@ -375,10 +357,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error {
} }
tmp.Close() tmp.Close()
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil { if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to restore %s: %w", candidate, err) return fmt.Errorf("failed to restore %s: %w", candidate, err)
} }
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil { if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil {
return err return err
} }
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate) fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
@@ -406,14 +388,21 @@ command = "agreety --cmd /bin/bash"
} }
tmp.Close() tmp.Close()
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil { if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
return fmt.Errorf("failed to write fallback greetd config: %w", err) return fmt.Errorf("failed to write fallback greetd config: %w", err)
} }
_ = privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath) _ = runSudoCommand(sudoPassword, "chmod", "644", configPath)
fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)") fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)")
return nil return nil
} }
func runSudoCommand(_ string, command string, args ...string) error {
cmd := exec.Command("sudo", append([]string{command}, args...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// suggestDisplayManagerRestore scans for installed DMs and re-enables one // suggestDisplayManagerRestore scans for installed DMs and re-enables one
func suggestDisplayManagerRestore(nonInteractive bool) { func suggestDisplayManagerRestore(nonInteractive bool) {
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
@@ -432,7 +421,10 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
enableDM := func(dm string) { enableDM := func(dm string) {
fmt.Printf(" Enabling %s...\n", dm) fmt.Printf(" Enabling %s...\n", dm)
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil { cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err) fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
} else { } else {
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm) fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
@@ -543,7 +535,7 @@ func resolveLocalWrapperShell() (string, error) {
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if !nonInteractive { if !nonInteractive {
fmt.Println("=== DMS Greeter Sync ===") fmt.Println("=== DMS Greeter Theme Sync ===")
fmt.Println() fmt.Println()
} }
@@ -631,7 +623,10 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if response != "n" && response != "no" { if response != "n" && response != "no" {
fmt.Printf("\nAdding user to %s group...\n", greeterGroup) fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil { addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
addUserCmd.Stdout = os.Stdout
addUserCmd.Stderr = os.Stderr
if err := addUserCmd.Run(); err != nil {
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
} }
fmt.Printf("✓ User added to %s group\n", greeterGroup) fmt.Printf("✓ User added to %s group\n", greeterGroup)
@@ -726,11 +721,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
} }
fmt.Println("\nSynchronizing DMS configurations...") fmt.Println("\nSynchronizing DMS configurations...")
if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{ if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil {
ForceGreeterAuth: forceAuth,
}, func() {
fmt.Println("\nConfiguring authentication...")
}); err != nil {
return err return err
} }
@@ -743,9 +734,8 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
fmt.Println("\n=== Sync Complete ===") fmt.Println("\n=== Sync Complete ===")
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.") fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.")
fmt.Println("Shared authentication settings were also checked and reconciled where needed.")
if forceAuth { if forceAuth {
fmt.Println("Authentication has been configured for fingerprint and U2F (where modules exist).") fmt.Println("PAM has been configured for fingerprint and U2F (where modules exist).")
} }
fmt.Println("The changes will be visible on the next login screen.") fmt.Println("The changes will be visible on the next login screen.")
@@ -856,19 +846,22 @@ func disableDisplayManager(dmName string) (bool, error) {
actionTaken := false actionTaken := false
if state.NeedsDisable { if state.NeedsDisable {
var action, actionVerb string var disableCmd *exec.Cmd
switch state.EnabledState { var actionVerb string
case "static":
if state.EnabledState == "static" {
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName) fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
action = "mask" disableCmd = exec.Command("sudo", "systemctl", "mask", dmName)
actionVerb = "masked" actionVerb = "masked"
default: } else {
fmt.Printf(" Disabling %s...\n", dmName) fmt.Printf(" Disabling %s...\n", dmName)
action = "disable" disableCmd = exec.Command("sudo", "systemctl", "disable", dmName)
actionVerb = "disabled" actionVerb = "disabled"
} }
if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil { disableCmd.Stdout = os.Stdout
disableCmd.Stderr = os.Stderr
if err := disableCmd.Run(); err != nil {
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err) return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
} }
@@ -909,7 +902,10 @@ func ensureGreetdEnabled() error {
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
fmt.Println(" Unmasking greetd...") fmt.Println(" Unmasking greetd...")
if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil { unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd")
unmaskCmd.Stdout = os.Stdout
unmaskCmd.Stderr = os.Stderr
if err := unmaskCmd.Run(); err != nil {
return fmt.Errorf("failed to unmask greetd: %w", err) return fmt.Errorf("failed to unmask greetd: %w", err)
} }
fmt.Println(" ✓ Unmasked greetd") fmt.Println(" ✓ Unmasked greetd")
@@ -921,7 +917,10 @@ func ensureGreetdEnabled() error {
fmt.Println(" Enabling greetd service...") fmt.Println(" Enabling greetd service...")
} }
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil { enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd")
enableCmd.Stdout = os.Stdout
enableCmd.Stderr = os.Stderr
if err := enableCmd.Run(); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err) return fmt.Errorf("failed to enable greetd: %w", err)
} }
@@ -951,7 +950,10 @@ func ensureGraphicalTarget() error {
currentTargetStr := strings.TrimSpace(string(currentTarget)) currentTargetStr := strings.TrimSpace(string(currentTarget))
if currentTargetStr != "graphical.target" { if currentTargetStr != "graphical.target" {
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr) fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil { setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target")
setDefaultCmd.Stdout = os.Stdout
setDefaultCmd.Stderr = os.Stderr
if err := setDefaultCmd.Run(); err != nil {
fmt.Println("⚠ Warning: Failed to set graphical.target as default") fmt.Println("⚠ Warning: Failed to set graphical.target as default")
fmt.Println(" Greeter may not start on boot. Run manually:") fmt.Println(" Greeter may not start on boot. Run manually:")
fmt.Println(" sudo systemctl set-default graphical.target") fmt.Println(" sudo systemctl set-default graphical.target")
@@ -1295,7 +1297,39 @@ func extractGreeterPathOverrideFromCommand(command string) string {
} }
func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) {
return sharedpam.ParseManagedGreeterPamAuth(pamText) if pamText == "" {
return false, false, false, false
}
lines := strings.Split(pamText, "\n")
inManaged := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch trimmed {
case greeter.GreeterPamManagedBlockStart:
managed = true
inManaged = true
continue
case greeter.GreeterPamManagedBlockEnd:
inManaged = false
continue
}
if strings.HasPrefix(trimmed, "# DMS greeter fingerprint") || strings.HasPrefix(trimmed, "# DMS greeter U2F") {
legacy = true
}
if !inManaged {
continue
}
if strings.Contains(trimmed, "pam_fprintd") {
fingerprint = true
}
if strings.Contains(trimmed, "pam_u2f") {
u2f = true
}
}
return managed, fingerprint, u2f, legacy
} }
func packageInstallHint() string { func packageInstallHint() string {
@@ -1605,29 +1639,29 @@ func checkGreeterStatus() error {
fmt.Println(" No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)") fmt.Println(" No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)")
} }
if legacyManaged { if legacyManaged {
fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms auth sync' to normalize.") fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms greeter sync' to normalize.")
allGood = false allGood = false
} }
enableFprintToggle, enableU2fToggle := false, false enableFprintToggle, enableU2fToggle := false, false
if enableFprint, enableU2f, settingsErr := sharedpam.ReadGreeterAuthToggles(homeDir); settingsErr == nil { if enableFprint, enableU2f, settingsErr := greeter.ReadGreeterAuthToggles(homeDir); settingsErr == nil {
enableFprintToggle = enableFprint enableFprintToggle = enableFprint
enableU2fToggle = enableU2f enableU2fToggle = enableU2f
} else { } else {
fmt.Printf(" Could not read greeter auth toggles from settings: %v\n", settingsErr) fmt.Printf(" Could not read greeter auth toggles from settings: %v\n", settingsErr)
} }
includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so") includedFprintFile := greeter.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so") includedU2fFile := greeter.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser() fprintAvailableForCurrentUser := greeter.FingerprintAuthAvailableForCurrentUser()
if managedFprint && includedFprintFile != "" { if managedFprint && includedFprintFile != "" {
fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile) fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile)
fmt.Println(" Double fingerprint auth detected — run 'dms auth sync' to resolve.") fmt.Println(" Double fingerprint auth detected — run 'dms greeter sync' to resolve.")
allGood = false allGood = false
} }
if managedU2f && includedU2fFile != "" { if managedU2f && includedU2fFile != "" {
fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile) fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile)
fmt.Println(" Double security-key auth detected — run 'dms auth sync' to resolve.") fmt.Println(" Double security-key auth detected — run 'dms greeter sync' to resolve.")
allGood = false allGood = false
} }

View File

@@ -1,87 +0,0 @@
package main
import (
"errors"
"reflect"
"testing"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
)
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
var calls []string
greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if dmsPath != "/tmp/dms" {
t.Fatalf("unexpected dmsPath %q", dmsPath)
}
if compositor != "niri" {
t.Fatalf("unexpected compositor %q", compositor)
}
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
calls = append(calls, "configs")
return nil
}
var gotOptions sharedpam.SyncAuthOptions
sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error {
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
gotOptions = options
calls = append(calls, "auth")
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{
ForceGreeterAuth: true,
}, func() {
calls = append(calls, "before-auth")
})
if err != nil {
t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err)
}
wantCalls := []string{"configs", "before-auth", "auth"}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("call order = %v, want %v", calls, wantCalls)
}
if !gotOptions.ForceGreeterAuth {
t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions)
}
}
func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
greeterConfigSyncFn = func(string, string, func(string), string) error {
return errors.New("config sync failed")
}
authCalled := false
sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error {
authCalled = true
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil)
if err == nil || err.Error() != "config sync failed" {
t.Fatalf("expected config sync error, got %v", err)
}
if authCalled {
t.Fatal("expected auth sync not to run after config sync failure")
}
}

View File

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

View File

@@ -1,277 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/spf13/cobra"
)
var systemCmd = &cobra.Command{
Use: "system",
Short: "System operations",
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
}
var systemUpdateCmd = &cobra.Command{
Use: "update",
Short: "Apply or list system updates",
Long: `Apply or list system updates across detected package managers.
Default behavior is to apply available updates after prompting for confirmation.
Use --check to list updates without applying.
Examples:
dms system update --check # list available updates
dms system update # apply updates (interactive prompt)
dms system update --noconfirm # apply updates without prompting
dms system update --dry # simulate without changing anything
dms system update --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`,
Run: runSystemUpdate,
}
var (
sysUpdateCheck bool
sysUpdateNoConfirm bool
sysUpdateDry bool
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd)
}
func runSystemUpdate(cmd *cobra.Command, args []string) {
switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS)
case sysUpdateCheck:
runSystemUpdateCheck()
default:
runSystemUpdateApply()
}
}
func selectBackends(ctx context.Context) []sysupdate.Backend {
sel := sysupdate.Select(ctx)
backends := sel.All()
if !sysUpdateNoFlatpak {
return backends
}
out := backends[:0]
for _, b := range backends {
if b.Repo() == sysupdate.RepoFlatpak {
continue
}
out = append(out, b)
}
return out
}
func runSystemUpdateCheck() {
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer cancel()
backends := selectBackends(ctx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
type backendResult struct {
ID string `json:"id"`
Display string `json:"displayName"`
Packages []sysupdate.Package `json:"packages"`
}
var results []backendResult
var allPkgs []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs})
allPkgs = append(allPkgs, pkgs...)
}
if sysUpdateJSON {
out, _ := json.MarshalIndent(map[string]any{
"backends": results,
"packages": allPkgs,
"error": errOrEmpty(firstErr),
"count": len(allPkgs),
}, "", " ")
fmt.Println(string(out))
return
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(allPkgs))
if firstErr != nil {
fmt.Printf("Error: %v\n", firstErr)
}
if len(allPkgs) == 0 {
return
}
fmt.Println()
for _, p := range allPkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
}
}
func runSystemUpdateApply() {
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
defer checkCancel()
backends := selectBackends(checkCtx)
if len(backends) == 0 {
log.Fatal("No supported package manager found")
}
pkgs, firstErr := collectUpdates(checkCtx, backends)
if firstErr != nil {
fmt.Printf("Warning: %v\n\n", firstErr)
}
printBackends(backends)
fmt.Printf("Updates: %d\n", len(pkgs))
if len(pkgs) == 0 {
fmt.Println("Nothing to upgrade.")
return
}
fmt.Println()
for _, p := range pkgs {
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
}
fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [y/N]: ") {
fmt.Println("Aborted.")
return
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
opts := sysupdate.UpgradeOptions{
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
DryRun: sysUpdateDry,
}
onLine := func(line string) { fmt.Println(line) }
for _, b := range backends {
fmt.Printf("\n== %s ==\n", b.DisplayName())
if err := b.Upgrade(ctx, opts, onLine); err != nil {
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
}
}
if sysUpdateDry {
fmt.Println("\nDry run complete (no changes applied).")
return
}
fmt.Println("\nUpgrade complete.")
}
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
var all []sysupdate.Package
var firstErr error
for _, b := range backends {
pkgs, err := b.CheckUpdates(ctx)
if err != nil && firstErr == nil {
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
}
all = append(all, pkgs...)
}
return all, firstErr
}
func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)},
})
if err != nil {
log.Fatalf("Failed: %v (is dms server running?)", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
fmt.Printf("Interval set to %d seconds.\n", seconds)
}
func promptYesNo(prompt string) bool {
if !stdinIsTTY() {
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
}
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
func printBackends(backends []sysupdate.Backend) {
if len(backends) == 0 {
return
}
names := make([]string, 0, len(backends))
for _, b := range backends {
names = append(names, b.DisplayName())
}
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
}
func stdinIsTTY() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func errOrEmpty(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}

View File

@@ -1,122 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra"
)
var trashCmd = &cobra.Command{
Use: "trash",
Short: "Manage the user's trash (XDG Trash spec 1.0)",
}
var trashPutCmd = &cobra.Command{
Use: "put <path...>",
Short: "Move files or directories into the trash",
Args: cobra.MinimumNArgs(1),
Run: runTrashPut,
}
var trashListCmd = &cobra.Command{
Use: "list",
Short: "List trashed items across all known trash directories",
Run: runTrashList,
}
var trashCountCmd = &cobra.Command{
Use: "count",
Short: "Print the total number of trashed items",
Run: runTrashCount,
}
var trashEmptyCmd = &cobra.Command{
Use: "empty",
Short: "Permanently delete every trashed item",
Run: runTrashEmpty,
}
var trashRestoreCmd = &cobra.Command{
Use: "restore <name>",
Short: "Restore a trashed item to its original location",
Args: cobra.ExactArgs(1),
Run: runTrashRestore,
}
var (
trashJSONOutput bool
trashRestoreDir string
)
func init() {
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
}
func runTrashPut(cmd *cobra.Command, args []string) {
var failed int
for _, p := range args {
if _, err := trash.Put(p); err != nil {
log.Errorf("trash %s: %v", p, err)
failed++
continue
}
fmt.Println(p)
}
if failed > 0 {
os.Exit(1)
}
}
func runTrashList(cmd *cobra.Command, args []string) {
entries, err := trash.List()
if err != nil {
log.Fatalf("list trash: %v", err)
}
if trashJSONOutput {
if entries == nil {
entries = []trash.Entry{}
}
out, _ := json.MarshalIndent(entries, "", " ")
fmt.Println(string(out))
return
}
if len(entries) == 0 {
fmt.Println("Trash is empty")
return
}
for _, e := range entries {
marker := "F"
if e.IsDir {
marker = "D"
}
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
}
}
func runTrashCount(cmd *cobra.Command, args []string) {
n, err := trash.Count()
if err != nil {
log.Fatalf("count trash: %v", err)
}
fmt.Println(n)
}
func runTrashEmpty(cmd *cobra.Command, args []string) {
if err := trash.Empty(); err != nil {
log.Fatalf("empty trash: %v", err)
}
}
func runTrashRestore(cmd *cobra.Command, args []string) {
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
log.Fatalf("restore: %v", err)
}
}

View File

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

View File

@@ -5,7 +5,6 @@ package main
import ( import (
"os" "os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
) )
@@ -15,27 +14,21 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd) updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateCmd)
rootCmd.SetHelpTemplate(getHelpTemplate()) rootCmd.SetHelpTemplate(getHelpTemplate())
} }
func main() { func main() {
clipboard.MaybeServeAndExit() if os.Geteuid() == 0 {
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
log.Fatal("This program should not be run as root. Exiting.") log.Fatal("This program should not be run as root. Exiting.")
} }

View File

@@ -5,7 +5,6 @@ package main
import ( import (
"os" "os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
) )
@@ -15,24 +14,18 @@ func init() {
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
rootCmd.SetHelpTemplate(getHelpTemplate()) rootCmd.SetHelpTemplate(getHelpTemplate())
} }
func main() { func main() {
clipboard.MaybeServeAndExit() if os.Geteuid() == 0 {
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
log.Fatal("This program should not be run as root. Exiting.") log.Fatal("This program should not be run as root. Exiting.")
} }

View File

@@ -80,16 +80,6 @@ func getRuntimeDir() string {
return os.TempDir() return os.TempDir()
} }
func appendLogEnv(env []string) []string {
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
env = append(env, "DMS_LOG_LEVEL="+v)
}
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
env = append(env, "DMS_LOG_FILE="+v)
}
return env
}
func hasSystemdRun() bool { func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run") _, err := exec.LookPath("systemd-run")
return err == nil return err == nil
@@ -226,8 +216,6 @@ func runShellInteractive(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
} }
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
@@ -471,8 +459,6 @@ func runShellDaemon(session bool) {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
} }
cmd.Env = appendLogEnv(cmd.Env)
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil { if err != nil {
log.Fatalf("Error opening /dev/null: %v", err) log.Fatalf("Error opening /dev/null: %v", err)

View File

@@ -7,22 +7,6 @@ import (
"strings" "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 { func isArchPackageInstalled(packageName string) bool {
cmd := exec.Command("pacman", "-Q", packageName) cmd := exec.Command("pacman", "-Q", packageName)
err := cmd.Run() err := cmd.Run()

View File

@@ -6,11 +6,11 @@ toolchain go1.26.1
require ( require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0 github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.24.0 github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0 github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
@@ -20,27 +20,28 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5 github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0 github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
golang.org/x/image v0.39.0 golang.org/x/image v0.36.0
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect github.com/fogleman/gg v1.3.0 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 // indirect github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect
@@ -48,37 +49,36 @@ require (
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.20.0 // indirect
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.2 github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.43.0 golang.org/x/sys v0.41.0
golang.org/x/text v0.36.0 golang.org/x/text v0.34.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View File

@@ -1,14 +1,14 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U= github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.24.0/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -24,22 +24,22 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
@@ -52,8 +52,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -66,12 +66,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8 h1:QRpwB1ans3fB3Cmeuog1ATzvXg/xhqubqiQi97xNO6E= github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
github.com/go-git/go-billy/v6 v6.0.0-20260424211911-732291493fb8/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU= github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -79,6 +79,8 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -93,20 +95,20 @@ github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7Dmvb
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -123,8 +125,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E= github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
@@ -153,33 +155,35 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,35 +0,0 @@
package blur
import (
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const extBackgroundEffectInterface = "ext_background_effect_manager_v1"
func ProbeSupport() (bool, error) {
display, err := client.Connect("")
if err != nil {
return false, err
}
defer display.Context().Close()
registry, err := display.GetRegistry()
if err != nil {
return false, err
}
found := false
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
switch e.Interface {
case extBackgroundEffectInterface:
found = true
}
})
if err := wlhelpers.Roundtrip(display, display.Context()); err != nil {
return false, err
}
return found, nil
}

View File

@@ -1,6 +1,7 @@
package clipboard package clipboard
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -12,166 +13,100 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" 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 { func Copy(data []byte, mimeType string) error {
return copyForkCached(data, mimeType, false) return CopyReader(bytes.NewReader(data), mimeType, false, false)
} }
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error { func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground { if foreground {
return serveClipboard(data, mimeType, pasteOnce) 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 copyForkCached(data, mimeType, pasteOnce) return CopyReader(bytes.NewReader(data), mimeType, foreground, pasteOnce)
} }
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error { func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if foreground { if !foreground {
buf, err := io.ReadAll(data) return copyFork(data, mimeType, pasteOnce)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
} }
return copyFork(data, mimeType, pasteOnce) return copyServeReader(data, mimeType, pasteOnce)
}
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(),
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 { func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce) args := []string{os.Args[0], "cl", "copy", "--foreground"}
if pasteOnce {
switch src := data.(type) { args = append(args, "--paste-once")
case *os.File:
cmd.Stdin = src
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)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
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)
}
return nil
} }
args = append(args, "--type", mimeType)
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if stdinSource, ok := data.(*os.File); ok {
cmd.Stdin = stdinSource
return cmd.Start()
}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
return nil
} }
func signalReady() { func copyServeReader(data io.Reader, mimeType string, pasteOnce bool) error {
if os.Getenv(envServe) == "" { cachedData, err := createClipboardCacheFile()
return if err != nil {
return fmt.Errorf("create clipboard cache file: %w", err)
} }
os.Stdout.Write([]byte{1}) 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) { func createClipboardCacheFile() (*os.File, error) {
@@ -194,7 +129,7 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*") return os.CreateTemp("", "dms-clipboard-*")
} }
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error { func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOnce bool) error {
display, err := wlclient.Connect("") display, err := wlclient.Connect("")
if err != nil { if err != nil {
return fmt.Errorf("wayland connect: %w", err) return fmt.Errorf("wayland connect: %w", err)
@@ -236,10 +171,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
if bindErr != nil { if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr) return fmt.Errorf("registry bind: %w", bindErr)
} }
if dataControlMgr == nil { if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1") return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
} }
defer dataControlMgr.Destroy() defer dataControlMgr.Destroy()
if seat == nil { if seat == nil {
return fmt.Errorf("no seat available") return fmt.Errorf("no seat available")
} }
@@ -278,12 +215,18 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
cancelled := make(chan struct{}) cancelled := make(chan struct{})
pasted := make(chan struct{}, 1) pasted := make(chan struct{}, 1)
sendErr := make(chan error, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false) defer syscall.Close(e.Fd)
file := os.NewFile(uintptr(e.Fd), "pipe") file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close() defer file.Close()
_, _ = file.Write(data) if err := writeTo(file); err != nil {
select {
case sendErr <- err:
default:
}
}
select { select {
case pasted <- struct{}{}: case pasted <- struct{}{}:
default: default:
@@ -299,12 +242,13 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
} }
display.Roundtrip() display.Roundtrip()
signalReady()
for { for {
select { select {
case <-cancelled: case <-cancelled:
return nil return nil
case err := <-sendErr:
return err
case <-pasted: case <-pasted:
if pasteOnce { if pasteOnce {
return nil return nil
@@ -558,10 +502,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
if bindErr != nil { if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr) return fmt.Errorf("registry bind: %w", bindErr)
} }
if dataControlMgr == nil { if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1") return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
} }
defer dataControlMgr.Destroy() defer dataControlMgr.Destroy()
if seat == nil { if seat == nil {
return fmt.Errorf("no seat available") return fmt.Errorf("no seat available")
} }
@@ -589,12 +535,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
pasted := make(chan struct{}, 1) pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false) defer syscall.Close(e.Fd)
file := os.NewFile(uintptr(e.Fd), "pipe") file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close() defer file.Close()
if data, ok := offerMap[e.MimeType]; ok { if data, ok := offerMap[e.MimeType]; ok {
_, _ = file.Write(data) file.Write(data)
} }
select { select {

View File

@@ -39,10 +39,11 @@ type LayerSurface struct {
wlSurface *client.Surface wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1 layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport viewport *wp_viewporter.WpViewport
wlPools [2]*client.ShmPool wlPool *client.ShmPool
wlBuffers [2]*client.Buffer wlBuffer *client.Buffer
slotBusy [2]bool bufferBusy bool
needsRedraw bool oldPool *client.ShmPool
oldBuffer *client.Buffer
scopyBuffer *client.Buffer scopyBuffer *client.Buffer
configured bool configured bool
hidden bool hidden bool
@@ -135,7 +136,6 @@ func (p *Picker) Run() (*Color, error) {
break break
} }
p.flushRedraws()
p.checkDone() p.checkDone()
} }
@@ -164,15 +164,6 @@ 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 { func (p *Picker) connect() error {
display, err := client.Connect("") display, err := client.Connect("")
if err != nil { if err != nil {
@@ -516,45 +507,47 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
} }
func (p *Picker) redrawSurface(ls *LayerSurface) { func (p *Picker) redrawSurface(ls *LayerSurface) {
slot := ls.state.FrontIndex()
if ls.slotBusy[slot] {
ls.needsRedraw = true
return
}
var renderBuf *ShmBuffer var renderBuf *ShmBuffer
switch { if ls.hidden {
case ls.hidden:
renderBuf = ls.state.RedrawScreenOnly() renderBuf = ls.state.RedrawScreenOnly()
default: } else {
renderBuf = ls.state.Redraw() renderBuf = ls.state.Redraw()
} }
if renderBuf == nil { if renderBuf == nil {
return return
} }
ls.needsRedraw = false if ls.oldBuffer != nil {
ls.oldBuffer.Destroy()
if ls.wlPools[slot] == nil { ls.oldBuffer = nil
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size())) }
if err != nil { if ls.oldPool != nil {
return ls.oldPool.Destroy()
} ls.oldPool = nil
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.slotBusy[slot] = true 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
logicalW, logicalH := ls.state.LogicalSize() logicalW, logicalH := ls.state.LogicalSize()
if logicalW == 0 || logicalH == 0 { if logicalW == 0 || logicalH == 0 {
@@ -573,7 +566,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
} }
_ = ls.wlSurface.SetBufferScale(bufferScale) _ = ls.wlSurface.SetBufferScale(bufferScale)
} }
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0) _ = ls.wlSurface.Attach(wlBuffer, 0, 0)
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH)) _ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
_ = ls.wlSurface.Commit() _ = ls.wlSurface.Commit()
@@ -641,7 +634,7 @@ func (p *Picker) setupPointerHandlers() {
} }
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY) p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.activeSurface.needsRedraw = true p.redrawSurface(p.activeSurface)
}) })
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) { p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
@@ -662,7 +655,7 @@ func (p *Picker) setupPointerHandlers() {
return return
} }
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY) p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
p.activeSurface.needsRedraw = true p.redrawSurface(p.activeSurface)
}) })
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) { p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
@@ -686,13 +679,17 @@ func (p *Picker) cleanup() {
if ls.scopyBuffer != nil { if ls.scopyBuffer != nil {
ls.scopyBuffer.Destroy() ls.scopyBuffer.Destroy()
} }
for i := range ls.wlBuffers { if ls.oldBuffer != nil {
if ls.wlBuffers[i] != nil { ls.oldBuffer.Destroy()
ls.wlBuffers[i].Destroy() }
} if ls.oldPool != nil {
if ls.wlPools[i] != nil { ls.oldPool.Destroy()
ls.wlPools[i].Destroy() }
} if ls.wlBuffer != nil {
ls.wlBuffer.Destroy()
}
if ls.wlPool != nil {
ls.wlPool.Destroy()
} }
if ls.viewport != nil { if ls.viewport != nil {
ls.viewport.Destroy() ls.viewport.Destroy()

View File

@@ -274,12 +274,6 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
return s.renderBufs[s.front] return s.renderBufs[s.front]
} }
func (s *SurfaceState) FrontIndex() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.front
}
func (s *SurfaceState) SwapBuffers() { func (s *SurfaceState) SwapBuffers() {
s.mu.Lock() s.mu.Lock()
s.front ^= 1 s.front ^= 1

View File

@@ -62,31 +62,12 @@ 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) { 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 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 { shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil { if replaceConfigs == nil {
return true return true
} }
replace, exists := replaceConfigs[configType] replace, exists := replaceConfigs[configType]
if !exists || replace { return !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 { switch wm {

View File

@@ -1,7 +1,6 @@
package config package config
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -625,168 +624,3 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
assert.Contains(t, string(newContent), "decorations = \"None\"") 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")
})
}

View File

@@ -137,7 +137,7 @@ bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout === # === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100% 100% bind = SUPER CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging === # === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow bindmd = SUPER, mouse:272, Move window, movewindow

View File

@@ -94,7 +94,6 @@ windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$ windowrule = tile on, match:class ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$ 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 ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$ windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$ windowrule = float on, match:class ^(blueman-manager)$

View File

@@ -224,7 +224,6 @@ window-rule {
open-floating false open-floating false
} }
window-rule { window-rule {
match app-id=r#"^org\.gnome\.Calculator$"#
match app-id=r#"^gnome-calculator$"# match app-id=r#"^gnome-calculator$"#
match app-id=r#"^galculator$"# match app-id=r#"^galculator$"#
match app-id=r#"^blueman-manager$"# match app-id=r#"^blueman-manager$"#

View File

@@ -11,7 +11,6 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
func init() { func init() {
@@ -243,7 +242,11 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
} }
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem} if a.packageInstalled("dms-shell-bin") {
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
} }
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency { func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
@@ -293,7 +296,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
LogOutput: "Installing base-devel development tools", LogOutput: "Installing base-devel development tools",
} }
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel") cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil { if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err) return fmt.Errorf("failed to install base-devel: %w", err)
} }
@@ -325,13 +328,6 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) 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 // Phase 3: System Packages
if len(systemPkgs) > 0 { if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
@@ -449,37 +445,6 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
return systemPkgs, aurPkgs, manualPkgs, variantMap 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 { func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 { if len(packages) == 0 {
return nil return nil
@@ -488,9 +453,6 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", "))) a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"} 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...) args = append(args, packages...)
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
@@ -502,7 +464,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
} }
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60) return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
} }
@@ -578,7 +540,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
var dmsShell []string var dmsShell []string
for _, pkg := range packages { for _, pkg := range packages {
if pkg == "dms-shell-git" { if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
dmsShell = append(dmsShell, pkg) dmsShell = append(dmsShell, pkg)
} else { } else {
isDep := false isDep := false
@@ -659,7 +621,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
} }
} }
if pkg == "dms-shell-git" { if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO") srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{ depsToRemove := []string{
"depends = quickshell", "depends = quickshell",
@@ -682,7 +644,15 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
} }
srcinfoPath = filepath.Join(packageDir, ".SRCINFO") 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{ progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, Phase: PhaseAURPackages,
Progress: startProgress + 0.3*(endProgress-startProgress), Progress: startProgress + 0.3*(endProgress-startProgress),
@@ -769,9 +739,42 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
CommandInfo: "sudo pacman -U built-package", CommandInfo: "sudo pacman -U built-package",
} }
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
var files []string var files []string
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*")) if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
files = matches // 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
}
if len(files) == 0 { if len(files) == 0 {
return fmt.Errorf("no package files found after building %s", pkg) return fmt.Errorf("no package files found after building %s", pkg)
@@ -780,7 +783,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
installArgs := []string{"pacman", "-U", "--noconfirm"} installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...) installArgs = append(installArgs, files...)
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " ")) installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files)) fileNames := make([]string, len(files))
for i, f := range files { for i, f := range files {

View File

@@ -14,7 +14,6 @@ import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
) )
@@ -56,6 +55,27 @@ func (b *BaseDistribution) logError(message string, err error) {
b.log(errorMsg) b.log(errorMsg)
} }
// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings.
// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote.
// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes.
func escapeSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
// MakeSudoCommand creates a command string that safely passes password to sudo.
// This helper escapes special characters in the password to prevent shell injection
// and syntax errors when passwords contain single quotes, apostrophes, or other special chars.
func MakeSudoCommand(sudoPassword string, command string) string {
return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command)
}
// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password.
// The password is properly escaped to prevent shell injection and syntax errors.
func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd {
cmdStr := MakeSudoCommand(sudoPassword, command)
return exec.CommandContext(ctx, "bash", "-c", cmdStr)
}
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency { func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
status := deps.StatusMissing status := deps.StatusMissing
if b.commandExists(name) { if b.commandExists(name) {
@@ -690,7 +710,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
} }
// Install to /usr/local/bin // Install to /usr/local/bin
installCmd := privesc.ExecCommand(ctx, sudoPassword, installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath)) fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
if err := installCmd.Run(); err != nil { if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install DMS binary: %w", err) return fmt.Errorf("failed to install DMS binary: %w", err)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import (
"testing" "testing"
) )
func writeTestFile(t *testing.T, path string, content string) { func writeTestJSON(t *testing.T, path string, content string) {
t.Helper() t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("failed to create parent dir for %s: %v", path, err) t.Fatalf("failed to create parent dir for %s: %v", path, err)
@@ -70,8 +70,8 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
t.Parallel() t.Parallel()
homeDir := t.TempDir() homeDir := t.TempDir()
writeTestFile(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON) writeTestJSON(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
writeTestFile(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON) writeTestJSON(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
state, err := resolveGreeterThemeSyncState(homeDir) state, err := resolveGreeterThemeSyncState(homeDir)
if err != nil { if err != nil {

View File

@@ -1,439 +0,0 @@
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
}

View File

@@ -1,459 +0,0 @@
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)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/sblinch/kdl-go"
"github.com/sblinch/kdl-go/document" "github.com/sblinch/kdl-go/document"
) )
@@ -291,7 +292,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
parser := NewNiriParser(filepath.Dir(overridePath)) parser := NewNiriParser(filepath.Dir(overridePath))
parser.currentSource = overridePath parser.currentSource = overridePath
doc, err := parseKDL(data) doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -50,103 +50,6 @@ type NiriParser struct {
conflictingConfigs map[string]*NiriKeyBinding 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 { func NewNiriParser(configDir string) *NiriParser {
return &NiriParser{ return &NiriParser{
configDir: configDir, configDir: configDir,
@@ -188,7 +91,7 @@ func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSec
return return
} }
doc, err := parseKDL(data) doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil { if err != nil {
return return
} }
@@ -256,7 +159,7 @@ func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, erro
return nil, fmt.Errorf("failed to read %s: %w", absPath, err) return nil, fmt.Errorf("failed to read %s: %w", absPath, err)
} }
doc, err := parseKDL(data) doc, err := kdl.Parse(strings.NewReader(string(data)))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
} }

View File

@@ -3,74 +3,9 @@ package providers
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"slices"
"testing" "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) { func TestNiriParseKeyCombo(t *testing.T) {
tests := []struct { tests := []struct {
combo string combo string

View File

@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"fmt" "fmt"
"os" "os"
"path/filepath"
"regexp" "regexp"
"sync" "sync"
"time" "time"
@@ -22,16 +21,7 @@ type FileLogger struct {
func NewFileLogger() (*FileLogger, error) { func NewFileLogger() (*FileLogger, error) {
timestamp := time.Now().Unix() 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) file, err := os.Create(logPath)
if err != nil { if err != nil {

View File

@@ -1,16 +1,12 @@
package log package log
import ( import (
"io"
"os" "os"
"regexp"
"strings" "strings"
"sync" "sync"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log" cblog "github.com/charmbracelet/log"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
) )
// Logger embeds the Charm Logger and adds Printf/Fatalf // Logger embeds the Charm Logger and adds Printf/Fatalf
@@ -25,26 +21,8 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
var ( var (
logger *Logger logger *Logger
initLogger sync.Once initLogger sync.Once
logMu sync.Mutex
logFile *os.File
logStderr io.Writer = os.Stderr
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
) )
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
// for the file sink so colored stderr stays colored while the file stays plain.
type ansiStripWriter struct{ w io.Writer }
func (a *ansiStripWriter) Write(p []byte) (int, error) {
stripped := ansiRe.ReplaceAll(p, nil)
if _, err := a.w.Write(stripped); err != nil {
return 0, err
}
return len(p), nil
}
func parseLogLevel(level string) cblog.Level { func parseLogLevel(level string) cblog.Level {
switch strings.ToLower(level) { switch strings.ToLower(level) {
case "debug": case "debug":
@@ -108,7 +86,7 @@ func GetLogger() *Logger {
SetString(" DEBUG"). SetString(" DEBUG").
Foreground(lipgloss.Color("4")) Foreground(lipgloss.Color("4"))
base := cblog.New(logStderr) base := cblog.New(os.Stderr)
base.SetStyles(styles) base.SetStyles(styles)
base.SetReportTimestamp(false) base.SetReportTimestamp(false)
@@ -120,85 +98,10 @@ func GetLogger() *Logger {
base.SetPrefix(" go") base.SetPrefix(" go")
logger = &Logger{base} logger = &Logger{base}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
_ = SetLogFile(path)
}
}) })
return logger return logger
} }
// SetLevel updates the active log level. Accepts the same strings as
// DMS_LOG_LEVEL. Unknown values default to info.
func SetLevel(level string) {
GetLogger().SetLevel(parseLogLevel(level))
}
// SetLogFile makes the logger append to path in addition to stderr. Passing an
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
//
// Color handling: charmbracelet/log auto-detects color support from its
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
// profile when stderr is a TTY and route the file through ansiStripWriter so
// the file stays plain while stderr keeps its colors.
func SetLogFile(path string) error {
logMu.Lock()
defer logMu.Unlock()
if logFile != nil {
logFile.Close()
logFile = nil
}
l := GetLogger()
if path == "" {
l.SetOutput(logStderr)
applyColorProfile(l, logStderr)
return nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
if err != nil {
return err
}
logFile = f
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
l.SetOutput(out)
applyColorProfile(l, logStderr)
return nil
}
// applyColorProfile forces the renderer's color profile to match what stderr
// would produce on its own, undoing the auto-downgrade triggered by wrapping
// stderr in a non-TTY writer (e.g. io.MultiWriter).
func applyColorProfile(l *Logger, stderr io.Writer) {
f, ok := stderr.(*os.File)
if !ok {
l.SetColorProfile(termenv.Ascii)
return
}
if isatty.IsTerminal(f.Fd()) {
l.SetColorProfile(termenv.ANSI)
return
}
l.SetColorProfile(termenv.Ascii)
}
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
// the singleton. Safe to call after CLI flags have rewritten the environment.
func ApplyEnvOverrides() {
GetLogger()
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
SetLevel(level)
}
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
if err := SetLogFile(path); err != nil {
Warnf("Failed to open log file %q: %v", path, err)
}
}
}
// * Convenience wrappers // * Convenience wrappers
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) } func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }

View File

@@ -60,7 +60,6 @@ var templateRegistry = []TemplateDef{
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"}, {ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"}, {ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"}, {ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"}, {ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal}, {ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal}, {ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},

View File

@@ -1,874 +0,0 @@
package pam
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
const (
GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)"
GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH"
LockscreenPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN AUTH (managed by dms greeter sync)"
LockscreenPamManagedBlockEnd = "# END DMS LOCKSCREEN AUTH"
LockscreenU2FPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN U2F AUTH (managed by dms auth sync)"
LockscreenU2FPamManagedBlockEnd = "# END DMS LOCKSCREEN U2F AUTH"
legacyGreeterPamFprintComment = "# DMS greeter fingerprint"
legacyGreeterPamU2FComment = "# DMS greeter U2F"
GreetdPamPath = "/etc/pam.d/greetd"
DankshellPamPath = "/etc/pam.d/dankshell"
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
)
var includedPamAuthFiles = []string{
"system-auth",
"common-auth",
"password-auth",
"system-login",
"system-local-login",
"common-auth-pc",
"login",
}
type AuthSettings struct {
EnableFprint bool `json:"enableFprint"`
EnableU2f bool `json:"enableU2f"`
GreeterEnableFprint bool `json:"greeterEnableFprint"`
GreeterEnableU2f bool `json:"greeterEnableU2f"`
}
type SyncAuthOptions struct {
HomeDir string
ForceGreeterAuth bool
}
type syncDeps struct {
pamDir string
greetdPath string
dankshellPath string
dankshellU2fPath string
isNixOS func() bool
readFile func(string) ([]byte, error)
stat func(string) (os.FileInfo, error)
createTemp func(string, string) (*os.File, error)
removeFile func(string) error
runSudoCmd func(string, string, ...string) error
pamModuleExists func(string) bool
fingerprintAvailableForCurrentUser func() bool
}
type lockscreenPamIncludeDirective struct {
target string
filterType string
}
type lockscreenPamResolver struct {
pamDir string
readFile func(string) ([]byte, error)
}
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: func(password, command string, args ...string) error {
return privesc.Run(context.Background(), password, append([]string{command}, args...)...)
},
pamModuleExists: pamModuleExists,
fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser,
}
}
func IsNixOS() bool {
_, err := os.Stat("/etc/NIXOS")
return err == nil
}
func ReadAuthSettings(homeDir string) (AuthSettings, error) {
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return AuthSettings{}, nil
}
return AuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err)
}
if strings.TrimSpace(string(data)) == "" {
return AuthSettings{}, nil
}
var settings AuthSettings
if err := json.Unmarshal(data, &settings); err != nil {
return AuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
}
return settings, nil
}
func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) {
settings, err := ReadAuthSettings(homeDir)
if err != nil {
return false, false, err
}
return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil
}
func SyncAuthConfig(logFunc func(string), sudoPassword string, options SyncAuthOptions) error {
return syncAuthConfigWithDeps(logFunc, sudoPassword, options, defaultSyncDeps())
}
func RemoveManagedGreeterPamBlock(logFunc func(string), sudoPassword string) error {
return removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, defaultSyncDeps())
}
func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options SyncAuthOptions, deps syncDeps) error {
homeDir := strings.TrimSpace(options.HomeDir)
if homeDir == "" {
var err error
homeDir, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
}
settings, err := ReadAuthSettings(homeDir)
if err != nil {
return err
}
if err := syncLockscreenPamConfigWithDeps(logFunc, sudoPassword, deps); err != nil {
return err
}
if err := syncLockscreenU2FPamConfigWithDeps(logFunc, sudoPassword, settings.EnableU2f, deps); err != nil {
return err
}
if _, err := deps.stat(deps.greetdPath); err != nil {
if os.IsNotExist(err) {
logFunc(" /etc/pam.d/greetd not found. Skipping greeter PAM sync.")
return nil
}
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
}
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
return err
}
return nil
}
func removeManagedGreeterPamBlockWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
if deps.isNixOS() {
return nil
}
data, err := deps.readFile(deps.greetdPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err)
}
originalContent := string(data)
stripped, removed := stripManagedGreeterPamBlock(originalContent)
strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped)
if !removed && !removedLegacy {
return nil
}
if err := writeManagedPamFile(strippedAgain, deps.greetdPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to write %s: %w", deps.greetdPath, err)
}
logFunc("✓ Removed DMS managed PAM block from " + deps.greetdPath)
return nil
}
func ParseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) {
if pamText == "" {
return false, false, false, false
}
lines := strings.Split(pamText, "\n")
inManaged := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch trimmed {
case GreeterPamManagedBlockStart:
managed = true
inManaged = true
continue
case GreeterPamManagedBlockEnd:
inManaged = false
continue
}
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
legacy = true
}
if !inManaged {
continue
}
if strings.Contains(trimmed, "pam_fprintd") {
fingerprint = true
}
if strings.Contains(trimmed, "pam_u2f") {
u2f = true
}
}
return managed, fingerprint, u2f, legacy
}
func StripManagedGreeterPamContent(pamText string) (string, bool) {
stripped, removed := stripManagedGreeterPamBlock(pamText)
stripped, removedLegacy := stripLegacyGreeterPamLines(stripped)
return stripped, removed || removedLegacy
}
func PamTextIncludesFile(pamText, filename string) bool {
lines := strings.Split(pamText, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.Contains(trimmed, filename) &&
(strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) {
return true
}
}
return false
}
func PamFileHasModule(pamFilePath, module string) bool {
data, err := os.ReadFile(pamFilePath)
if err != nil {
return false
}
return pamContentHasModule(string(data), module)
}
func DetectIncludedPamModule(pamText, module string) string {
return detectIncludedPamModule(pamText, module, defaultSyncDeps())
}
func detectIncludedPamModule(pamText, module string, deps syncDeps) string {
for _, includedFile := range includedPamAuthFiles {
if !PamTextIncludesFile(pamText, includedFile) {
continue
}
path := filepath.Join(deps.pamDir, includedFile)
data, err := deps.readFile(path)
if err != nil {
continue
}
if pamContentHasModule(string(data), module) {
return includedFile
}
}
return ""
}
func pamContentHasModule(content, module string) bool {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.Contains(trimmed, module) {
return true
}
}
return false
}
func hasManagedLockscreenPamFile(content string) bool {
return strings.Contains(content, LockscreenPamManagedBlockStart) &&
strings.Contains(content, LockscreenPamManagedBlockEnd)
}
func hasManagedLockscreenU2FPamFile(content string) bool {
return strings.Contains(content, LockscreenU2FPamManagedBlockStart) &&
strings.Contains(content, LockscreenU2FPamManagedBlockEnd)
}
func pamDirectiveType(line string) string {
fields := strings.Fields(line)
if len(fields) == 0 {
return ""
}
directiveType := strings.TrimPrefix(fields[0], "-")
switch directiveType {
case "auth", "account", "password", "session":
return directiveType
default:
return ""
}
}
func isExcludedLockscreenPamLine(line string) bool {
for _, field := range strings.Fields(line) {
if strings.HasPrefix(field, "#") {
break
}
if strings.Contains(field, "pam_u2f") || strings.Contains(field, "pam_fprintd") {
return true
}
}
return false
}
func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string) (lockscreenPamIncludeDirective, bool) {
fields := strings.Fields(trimmed)
if len(fields) >= 2 && fields[0] == "@include" {
return lockscreenPamIncludeDirective{
target: fields[1],
filterType: inheritedFilter,
}, true
}
if len(fields) >= 3 && (fields[1] == "include" || fields[1] == "substack") {
lineType := pamDirectiveType(trimmed)
if lineType == "" {
return lockscreenPamIncludeDirective{}, false
}
return lockscreenPamIncludeDirective{
target: fields[2],
filterType: lineType,
}, true
}
if len(fields) >= 3 && fields[1] == "@include" {
lineType := pamDirectiveType(trimmed)
if lineType == "" {
return lockscreenPamIncludeDirective{}, false
}
return lockscreenPamIncludeDirective{
target: fields[2],
filterType: lineType,
}, true
}
return lockscreenPamIncludeDirective{}, false
}
func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) {
if strings.TrimSpace(target) == "" {
return "", fmt.Errorf("empty PAM include target")
}
cleanPamDir := filepath.Clean(pamDir)
if filepath.IsAbs(target) {
cleanTarget := filepath.Clean(target)
if filepath.Dir(cleanTarget) != cleanPamDir {
return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target)
}
return cleanTarget, nil
}
cleanTarget := filepath.Clean(target)
if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid PAM include target: %s", target)
}
return filepath.Join(cleanPamDir, cleanTarget), nil
}
func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
if err != nil {
return nil, err
}
for _, seen := range stack {
if seen == path {
chain := append(append([]string{}, stack...), path)
display := make([]string, 0, len(chain))
for _, item := range chain {
display = append(display, filepath.Base(item))
}
return nil, fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
}
}
data, err := r.readFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read PAM file %s: %w", path, err)
}
var resolved []string
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
rawLine = strings.TrimRight(rawLine, "\r")
trimmed := strings.TrimSpace(rawLine)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || trimmed == "#%PAM-1.0" {
continue
}
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
lineType := pamDirectiveType(trimmed)
if filterType != "" && lineType != "" && lineType != filterType {
continue
}
nested, err := r.resolveService(include.target, include.filterType, append(stack, path))
if err != nil {
return nil, err
}
resolved = append(resolved, nested...)
continue
}
lineType := pamDirectiveType(trimmed)
if lineType == "" {
return nil, fmt.Errorf("unsupported PAM directive in %s: %s", filepath.Base(path), trimmed)
}
if filterType != "" && lineType != filterType {
continue
}
if isExcludedLockscreenPamLine(trimmed) {
continue
}
resolved = append(resolved, rawLine)
}
return resolved, nil
}
func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
resolver := lockscreenPamResolver{
pamDir: pamDir,
readFile: readFile,
}
resolvedLines, err := resolver.resolveService("login", "", nil)
if err != nil {
return "", err
}
if len(resolvedLines) == 0 {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
}
hasAuth := false
for _, line := range resolvedLines {
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
hasAuth = true
break
}
}
if !hasAuth {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
}
var b strings.Builder
b.WriteString("#%PAM-1.0\n")
b.WriteString(LockscreenPamManagedBlockStart + "\n")
for _, line := range resolvedLines {
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteString(LockscreenPamManagedBlockEnd + "\n")
return b.String(), nil
}
func buildManagedLockscreenU2FPamContent() string {
var b strings.Builder
b.WriteString("#%PAM-1.0\n")
b.WriteString(LockscreenU2FPamManagedBlockStart + "\n")
b.WriteString("auth required pam_u2f.so cue nouserok timeout=10\n")
b.WriteString(LockscreenU2FPamManagedBlockEnd + "\n")
return b.String()
}
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
if deps.isNixOS() {
logFunc(" NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.")
return nil
}
existingData, err := deps.readFile(deps.dankshellPath)
if err == nil {
if !hasManagedLockscreenPamFile(string(existingData)) {
logFunc(" Custom /etc/pam.d/dankshell found (no DMS block). Skipping.")
return nil
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
}
content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
if err != nil {
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
}
if err := writeManagedPamFile(content, deps.dankshellPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to write %s: %w", deps.dankshellPath, err)
}
logFunc("✓ Created or updated /etc/pam.d/dankshell for lock screen authentication")
return nil
}
func syncLockscreenU2FPamConfigWithDeps(logFunc func(string), sudoPassword string, enabled bool, deps syncDeps) error {
if deps.isNixOS() {
logFunc(" NixOS detected. DMS does not manage /etc/pam.d/dankshell-u2f on NixOS. Keep using the bundled U2F helper or configure a custom PAM service yourself.")
return nil
}
existingData, err := deps.readFile(deps.dankshellU2fPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read %s: %w", deps.dankshellU2fPath, err)
}
if enabled {
if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) {
logFunc(" Custom /etc/pam.d/dankshell-u2f found (no DMS block). Skipping.")
return nil
}
if err := writeManagedPamFile(buildManagedLockscreenU2FPamContent(), deps.dankshellU2fPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to write %s: %w", deps.dankshellU2fPath, err)
}
logFunc("✓ Created or updated /etc/pam.d/dankshell-u2f for lock screen security-key authentication")
return nil
}
if os.IsNotExist(err) {
return nil
}
if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) {
logFunc(" Custom /etc/pam.d/dankshell-u2f found (no DMS block). Leaving it untouched.")
return nil
}
if err := deps.runSudoCmd(sudoPassword, "rm", "-f", deps.dankshellU2fPath); err != nil {
return fmt.Errorf("failed to remove %s: %w", deps.dankshellU2fPath, err)
}
logFunc("✓ Removed DMS-managed /etc/pam.d/dankshell-u2f")
return nil
}
func stripManagedGreeterPamBlock(content string) (string, bool) {
lines := strings.Split(content, "\n")
filtered := make([]string, 0, len(lines))
inManagedBlock := false
removed := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == GreeterPamManagedBlockStart {
inManagedBlock = true
removed = true
continue
}
if trimmed == GreeterPamManagedBlockEnd {
inManagedBlock = false
removed = true
continue
}
if inManagedBlock {
removed = true
continue
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\n"), removed
}
func stripLegacyGreeterPamLines(content string) (string, bool) {
lines := strings.Split(content, "\n")
filtered := make([]string, 0, len(lines))
removed := false
for i := 0; i < len(lines); i++ {
trimmed := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
removed = true
if i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1])
if strings.HasPrefix(nextLine, "auth") &&
(strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) {
i++
}
}
continue
}
filtered = append(filtered, lines[i])
}
return strings.Join(filtered, "\n"), removed
}
func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) {
lines := strings.Split(content, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") {
block := strings.Join(blockLines, "\n")
prefix := strings.Join(lines[:i], "\n")
suffix := strings.Join(lines[i:], "\n")
switch {
case prefix == "":
return block + "\n" + suffix, nil
case suffix == "":
return prefix + "\n" + block, nil
default:
return prefix + "\n" + block + "\n" + suffix, nil
}
}
}
return "", fmt.Errorf("no auth directive found in %s", greetdPamPath)
}
func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, settings AuthSettings, forceAuth bool, deps syncDeps) error {
var wantFprint, wantU2f bool
fprintToggleEnabled := forceAuth
u2fToggleEnabled := forceAuth
if forceAuth {
wantFprint = deps.pamModuleExists("pam_fprintd.so")
wantU2f = deps.pamModuleExists("pam_u2f.so")
} else {
fprintToggleEnabled = settings.GreeterEnableFprint
u2fToggleEnabled = settings.GreeterEnableU2f
fprintModule := deps.pamModuleExists("pam_fprintd.so")
u2fModule := deps.pamModuleExists("pam_u2f.so")
wantFprint = settings.GreeterEnableFprint && fprintModule
wantU2f = settings.GreeterEnableU2f && u2fModule
if settings.GreeterEnableFprint && !fprintModule {
logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.")
}
if settings.GreeterEnableU2f && !u2fModule {
logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.")
}
}
if deps.isNixOS() {
logFunc(" NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.")
logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).")
return nil
}
pamData, err := deps.readFile(deps.greetdPath)
if err != nil {
return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err)
}
originalContent := string(pamData)
content, _ := stripManagedGreeterPamBlock(originalContent)
content, _ = stripLegacyGreeterPamLines(content)
includedFprintFile := detectIncludedPamModule(content, "pam_fprintd.so", deps)
includedU2fFile := detectIncludedPamModule(content, "pam_u2f.so", deps)
fprintAvailableForCurrentUser := deps.fingerprintAvailableForCurrentUser()
if wantFprint && includedFprintFile != "" {
logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.")
wantFprint = false
}
if wantU2f && includedU2fFile != "" {
logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.")
wantU2f = false
}
if !wantFprint && includedFprintFile != "" {
if fprintToggleEnabled {
logFunc(" Fingerprint auth is still enabled via included " + includedFprintFile + ".")
if fprintAvailableForCurrentUser {
logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.")
} else {
logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.")
}
} else {
if fprintAvailableForCurrentUser {
logFunc(" Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.")
logFunc(" Password login will work but may be delayed while the fingerprint module runs first.")
logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro())
} else {
logFunc(" pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.")
logFunc(" Password auth remains the effective login path.")
}
}
}
if !wantU2f && includedU2fFile != "" {
if u2fToggleEnabled {
logFunc(" Security-key auth is still enabled via included " + includedU2fFile + ".")
logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.")
} else {
logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.")
logFunc(" " + pamManagerHintForCurrentDistro())
}
}
if wantFprint || wantU2f {
blockLines := []string{GreeterPamManagedBlockStart}
if wantFprint {
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
}
if wantU2f {
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
}
blockLines = append(blockLines, GreeterPamManagedBlockEnd)
content, err = insertManagedGreeterPamBlock(content, blockLines, deps.greetdPath)
if err != nil {
return err
}
}
if content == originalContent {
return nil
}
if err := writeManagedPamFile(content, deps.greetdPath, sudoPassword, deps); err != nil {
return fmt.Errorf("failed to install updated PAM config at %s: %w", deps.greetdPath, err)
}
if wantFprint || wantU2f {
logFunc("✓ Configured greetd PAM for fingerprint/U2F")
} else {
logFunc("✓ Cleared DMS-managed greeter PAM auth block")
}
return nil
}
func writeManagedPamFile(content string, destPath string, sudoPassword string, deps syncDeps) error {
tmpFile, err := deps.createTemp("", "dms-pam-*.conf")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
defer func() {
_ = deps.removeFile(tmpPath)
}()
if _, err := tmpFile.WriteString(content); err != nil {
tmpFile.Close()
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := deps.runSudoCmd(sudoPassword, "cp", tmpPath, destPath); err != nil {
return err
}
if err := deps.runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", destPath, err)
}
return nil
}
func pamManagerHintForCurrentDistro() string {
osInfo, err := distros.GetOSInfo()
if err != nil {
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
}
config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists {
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
}
switch config.Family {
case distros.FamilyFedora:
return "Disable it in authselect to force password-only greeter login."
case distros.FamilyDebian, distros.FamilyUbuntu:
return "Disable it in pam-auth-update to force password-only greeter login."
default:
return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
}
}
func pamModuleExists(module string) bool {
for _, libDir := range []string{
"/usr/lib64/security",
"/usr/lib/security",
"/lib64/security",
"/lib/security",
"/lib/x86_64-linux-gnu/security",
"/usr/lib/x86_64-linux-gnu/security",
"/lib/aarch64-linux-gnu/security",
"/usr/lib/aarch64-linux-gnu/security",
"/run/current-system/sw/lib64/security",
"/run/current-system/sw/lib/security",
} {
if _, err := os.Stat(filepath.Join(libDir, module)); err == nil {
return true
}
}
return false
}
func hasEnrolledFingerprintOutput(output string) bool {
lower := strings.ToLower(output)
if strings.Contains(lower, "no fingers enrolled") ||
strings.Contains(lower, "no fingerprints enrolled") ||
strings.Contains(lower, "no prints enrolled") {
return false
}
if strings.Contains(lower, "has fingers enrolled") ||
strings.Contains(lower, "has fingerprints enrolled") {
return true
}
for _, line := range strings.Split(lower, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "finger:") {
return true
}
if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") {
return true
}
}
return false
}
func FingerprintAuthAvailableForCurrentUser() bool {
username := strings.TrimSpace(os.Getenv("SUDO_USER"))
if username == "" {
username = strings.TrimSpace(os.Getenv("USER"))
}
if username == "" {
out, err := exec.Command("id", "-un").Output()
if err == nil {
username = strings.TrimSpace(string(out))
}
}
return fingerprintAuthAvailableForUser(username)
}
func fingerprintAuthAvailableForUser(username string) bool {
username = strings.TrimSpace(username)
if username == "" {
return false
}
if !pamModuleExists("pam_fprintd.so") {
return false
}
if _, err := exec.LookPath("fprintd-list"); err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput()
if err != nil {
return false
}
return hasEnrolledFingerprintOutput(string(out))
}

View File

@@ -1,671 +0,0 @@
package pam
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("failed to create parent dir for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write %s: %v", path, err)
}
}
type pamTestEnv struct {
pamDir string
greetdPath string
dankshellPath string
dankshellU2fPath string
tmpDir string
homeDir string
availableModules map[string]bool
fingerprintAvailable bool
}
func newPamTestEnv(t *testing.T) *pamTestEnv {
t.Helper()
root := t.TempDir()
pamDir := filepath.Join(root, "pam.d")
tmpDir := filepath.Join(root, "tmp")
homeDir := filepath.Join(root, "home")
for _, dir := range []string{pamDir, tmpDir, homeDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("failed to create %s: %v", dir, err)
}
}
return &pamTestEnv{
pamDir: pamDir,
greetdPath: filepath.Join(pamDir, "greetd"),
dankshellPath: filepath.Join(pamDir, "dankshell"),
dankshellU2fPath: filepath.Join(pamDir, "dankshell-u2f"),
tmpDir: tmpDir,
homeDir: homeDir,
availableModules: map[string]bool{},
}
}
func (e *pamTestEnv) writePamFile(t *testing.T, name string, content string) {
t.Helper()
writeTestFile(t, filepath.Join(e.pamDir, name), content)
}
func (e *pamTestEnv) writeSettings(t *testing.T, content string) {
t.Helper()
writeTestFile(t, filepath.Join(e.homeDir, ".config", "DankMaterialShell", "settings.json"), content)
}
func (e *pamTestEnv) deps(isNixOS bool) syncDeps {
return syncDeps{
pamDir: e.pamDir,
greetdPath: e.greetdPath,
dankshellPath: e.dankshellPath,
dankshellU2fPath: e.dankshellU2fPath,
isNixOS: func() bool { return isNixOS },
readFile: os.ReadFile,
stat: os.Stat,
createTemp: func(_ string, pattern string) (*os.File, error) {
return os.CreateTemp(e.tmpDir, pattern)
},
removeFile: os.Remove,
runSudoCmd: func(_ string, command string, args ...string) error {
switch command {
case "cp":
if len(args) != 2 {
return fmt.Errorf("unexpected cp args: %v", args)
}
data, err := os.ReadFile(args[0])
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(args[1]), 0o755); err != nil {
return err
}
return os.WriteFile(args[1], data, 0o644)
case "chmod":
if len(args) != 2 {
return fmt.Errorf("unexpected chmod args: %v", args)
}
return nil
case "rm":
if len(args) != 2 || args[0] != "-f" {
return fmt.Errorf("unexpected rm args: %v", args)
}
if err := os.Remove(args[1]); err != nil && !os.IsNotExist(err) {
return err
}
return nil
default:
return fmt.Errorf("unexpected sudo command: %s %v", command, args)
}
},
pamModuleExists: func(module string) bool {
return e.availableModules[module]
},
fingerprintAvailableForCurrentUser: func() bool {
return e.fingerprintAvailable
},
}
}
func readFileString(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read %s: %v", path, err)
}
return string(data)
}
func TestHasManagedLockscreenPamFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want bool
}{
{
name: "both markers present",
content: "#%PAM-1.0\n" +
LockscreenPamManagedBlockStart + "\n" +
"auth sufficient pam_unix.so\n" +
LockscreenPamManagedBlockEnd + "\n",
want: true,
},
{
name: "missing end marker is not managed",
content: "#%PAM-1.0\n" +
LockscreenPamManagedBlockStart + "\n" +
"auth sufficient pam_unix.so\n",
want: false,
},
{
name: "custom file is not managed",
content: "#%PAM-1.0\nauth sufficient pam_unix.so\n",
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := hasManagedLockscreenPamFile(tt.content); got != tt.want {
t.Fatalf("hasManagedLockscreenPamFile() = %v, want %v", got, tt.want)
}
})
}
}
func TestBuildManagedLockscreenPamContent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string
wantContains []string
wantNotContains []string
wantCounts map[string]int
wantErr string
}{
{
name: "preserves custom modules and strips direct u2f and fprint directives",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth include system-auth\n" +
"account include system-auth\n" +
"session include system-auth\n",
"system-auth": "auth requisite pam_nologin.so\n" +
"auth sufficient pam_unix.so try_first_pass nullok\n" +
"auth sufficient pam_u2f.so cue\n" +
"auth sufficient pam_fprintd.so max-tries=1\n" +
"auth required pam_radius_auth.so conf=/etc/raddb/server\n" +
"account required pam_access.so\n" +
"session optional pam_lastlog.so silent\n",
},
wantContains: []string{
"#%PAM-1.0",
LockscreenPamManagedBlockStart,
LockscreenPamManagedBlockEnd,
"auth requisite pam_nologin.so",
"auth sufficient pam_unix.so try_first_pass nullok",
"auth required pam_radius_auth.so conf=/etc/raddb/server",
"account required pam_access.so",
"session optional pam_lastlog.so silent",
},
wantNotContains: []string{
"pam_u2f",
"pam_fprintd",
},
wantCounts: map[string]int{
"auth required pam_radius_auth.so conf=/etc/raddb/server": 1,
"account required pam_access.so": 1,
},
},
{
name: "resolves nested include substack and @include transitively",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth include system-auth\n" +
"account include system-auth\n" +
"password include system-auth\n" +
"session include system-auth\n",
"system-auth": "auth substack custom-auth\n" +
"account include custom-auth\n" +
"password include custom-auth\n" +
"session @include common-session\n",
"custom-auth": "auth required pam_custom.so one=two\n" +
"account required pam_custom_account.so\n" +
"password required pam_custom_password.so\n",
"common-session": "session optional pam_fprintd.so max-tries=1\n" +
"session optional pam_lastlog.so silent\n",
},
wantContains: []string{
"auth required pam_custom.so one=two",
"account required pam_custom_account.so",
"password required pam_custom_password.so",
"session optional pam_lastlog.so silent",
},
wantNotContains: []string{
"pam_fprintd",
},
wantCounts: map[string]int{
"auth required pam_custom.so one=two": 1,
"account required pam_custom_account.so": 1,
"password required pam_custom_password.so": 1,
"session optional pam_lastlog.so silent": 1,
},
},
{
name: "missing include fails",
files: map[string]string{
"login": "#%PAM-1.0\nauth include missing-auth\n",
},
wantErr: "failed to read PAM file",
},
{
name: "cyclic include fails",
files: map[string]string{
"login": "#%PAM-1.0\nauth include system-auth\n",
"system-auth": "auth include login\n",
},
wantErr: "cyclic PAM include detected",
},
{
name: "no auth directives remain after filtering fails",
files: map[string]string{
"login": "#%PAM-1.0\nauth include system-auth\n",
"system-auth": "auth sufficient pam_u2f.so cue\n",
},
wantErr: "no auth directives remained after filtering",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
for name, content := range tt.files {
env.writePamFile(t, name, content)
}
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
for _, want := range tt.wantContains {
if !strings.Contains(content, want) {
t.Errorf("missing expected string %q in output:\n%s", want, content)
}
}
for _, notWant := range tt.wantNotContains {
if strings.Contains(content, notWant) {
t.Errorf("unexpected string %q found in output:\n%s", notWant, content)
}
}
for want, wantCount := range tt.wantCounts {
if gotCount := strings.Count(content, want); gotCount != wantCount {
t.Errorf("count for %q = %d, want %d\noutput:\n%s", want, gotCount, wantCount, content)
}
}
})
}
}
func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("custom dankshell file is skipped untouched", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
customContent := "#%PAM-1.0\nauth required pam_unix.so\n"
env.writePamFile(t, "dankshell", customContent)
var logs []string
err := syncLockscreenPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err)
}
if got := readFileString(t, env.dankshellPath); got != customContent {
t.Fatalf("custom dankshell content changed\ngot:\n%s\nwant:\n%s", got, customContent)
}
if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell found") {
t.Fatalf("expected custom-file skip log, got %v", logs)
}
})
t.Run("managed dankshell file is rewritten from resolved login stack", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\nauth sufficient pam_u2f.so cue\naccount required pam_access.so\n")
env.writePamFile(t, "dankshell", "#%PAM-1.0\n"+LockscreenPamManagedBlockStart+"\nauth required pam_env.so\n"+LockscreenPamManagedBlockEnd+"\n")
var logs []string
err := syncLockscreenPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err)
}
output := readFileString(t, env.dankshellPath)
for _, want := range []string{
LockscreenPamManagedBlockStart,
"auth sufficient pam_unix.so try_first_pass nullok",
"account required pam_access.so",
LockscreenPamManagedBlockEnd,
} {
if !strings.Contains(output, want) {
t.Errorf("missing expected string %q in rewritten dankshell:\n%s", want, output)
}
}
if strings.Contains(output, "pam_u2f") {
t.Errorf("rewritten dankshell still contains pam_u2f:\n%s", output)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell") {
t.Fatalf("expected success log, got %v", logs)
}
})
t.Run("mutable systems fail when login stack cannot be converted safely", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
err := syncLockscreenPamConfigWithDeps(func(string) {}, "", env.deps(false))
if err == nil {
t.Fatal("expected error when login PAM file is missing, got nil")
}
if !strings.Contains(err.Error(), "failed to build") {
t.Fatalf("error = %q, want substring %q", err.Error(), "failed to build")
}
})
t.Run("NixOS remains informational and does not write dankshell", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
var logs []string
err := syncLockscreenPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", env.deps(true))
if err != nil {
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
}
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
}
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
}
})
}
func TestSyncLockscreenU2FPamConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("enabled creates managed file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
var logs []string
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", true, env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
got := readFileString(t, env.dankshellU2fPath)
if got != buildManagedLockscreenU2FPamContent() {
t.Fatalf("unexpected managed dankshell-u2f content:\n%s", got)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell-u2f") {
t.Fatalf("expected create log, got %v", logs)
}
})
t.Run("enabled rewrites existing managed file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\n"+LockscreenU2FPamManagedBlockStart+"\nauth required pam_u2f.so old\n"+LockscreenU2FPamManagedBlockEnd+"\n")
if err := syncLockscreenU2FPamConfigWithDeps(func(string) {}, "", true, env.deps(false)); err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() {
t.Fatalf("managed dankshell-u2f was not rewritten:\n%s", got)
}
})
t.Run("disabled removes DMS-managed file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "dankshell-u2f", buildManagedLockscreenU2FPamContent())
var logs []string
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", false, env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
t.Fatalf("expected managed dankshell-u2f to be removed, stat err = %v", err)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Removed DMS-managed /etc/pam.d/dankshell-u2f") {
t.Fatalf("expected removal log, got %v", logs)
}
})
t.Run("disabled preserves custom file", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
customContent := "#%PAM-1.0\nauth required pam_u2f.so cue\n"
env.writePamFile(t, "dankshell-u2f", customContent)
var logs []string
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", false, env.deps(false))
if err != nil {
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
}
if got := readFileString(t, env.dankshellU2fPath); got != customContent {
t.Fatalf("custom dankshell-u2f content changed\ngot:\n%s\nwant:\n%s", got, customContent)
}
if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell-u2f found") {
t.Fatalf("expected custom-file log, got %v", logs)
}
})
}
func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("adds managed block for enabled auth modules", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
settings := AuthSettings{GreeterEnableFprint: true, GreeterEnableU2f: true}
if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil {
t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err)
}
got := readFileString(t, env.greetdPath)
for _, want := range []string{
GreeterPamManagedBlockStart,
"auth sufficient pam_fprintd.so max-tries=1 timeout=5",
"auth sufficient pam_u2f.so cue nouserok timeout=10",
GreeterPamManagedBlockEnd,
} {
if !strings.Contains(got, want) {
t.Errorf("missing expected string %q in greetd PAM:\n%s", want, got)
}
}
if strings.Index(got, GreeterPamManagedBlockStart) > strings.Index(got, "auth include system-auth") {
t.Fatalf("managed block was not inserted before first auth line:\n%s", got)
}
})
t.Run("avoids duplicate fingerprint when included stack already provides it", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.fingerprintAvailable = true
original := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n"
env.writePamFile(t, "greetd", original)
env.writePamFile(t, "system-auth", "auth sufficient pam_fprintd.so max-tries=1\nauth sufficient pam_unix.so\n")
settings := AuthSettings{GreeterEnableFprint: true}
if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil {
t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err)
}
got := readFileString(t, env.greetdPath)
if got != original {
t.Fatalf("greetd PAM changed despite included pam_fprintd stack\ngot:\n%s\nwant:\n%s", got, original)
}
if strings.Contains(got, GreeterPamManagedBlockStart) {
t.Fatalf("managed block should not be inserted when included stack already has pam_fprintd:\n%s", got)
}
})
}
func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "greetd", "#%PAM-1.0\n"+
legacyGreeterPamFprintComment+"\n"+
"auth sufficient pam_fprintd.so max-tries=1\n"+
GreeterPamManagedBlockStart+"\n"+
"auth sufficient pam_u2f.so cue nouserok timeout=10\n"+
GreeterPamManagedBlockEnd+"\n"+
"auth include system-auth\n")
if err := removeManagedGreeterPamBlockWithDeps(func(string) {}, "", env.deps(false)); err != nil {
t.Fatalf("removeManagedGreeterPamBlockWithDeps returned error: %v", err)
}
got := readFileString(t, env.greetdPath)
if strings.Contains(got, GreeterPamManagedBlockStart) || strings.Contains(got, legacyGreeterPamFprintComment) {
t.Fatalf("managed or legacy DMS auth lines remained in greetd PAM:\n%s", got)
}
if !strings.Contains(got, "auth include system-auth") {
t.Fatalf("expected non-DMS greetd auth lines to remain:\n%s", got)
}
}
func TestSyncAuthConfigWithDeps(t *testing.T) {
t.Parallel()
t.Run("creates lockscreen targets and skips greetd when greeter is not installed", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writeSettings(t, `{"enableU2f":true}`)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n")
var logs []string
err := syncAuthConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
if _, err := os.Stat(env.dankshellPath); err != nil {
t.Fatalf("expected dankshell to be created: %v", err)
}
if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() {
t.Fatalf("unexpected dankshell-u2f content:\n%s", got)
}
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "greetd not found") {
t.Fatalf("expected greetd skip log, got %v", logs)
}
})
t.Run("separate greeter and lockscreen toggles are respected", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.writeSettings(t, `{"enableU2f":false,"greeterEnableFprint":true,"greeterEnableU2f":false}`)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n")
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
err := syncAuthConfigWithDeps(func(string) {}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
dankshell := readFileString(t, env.dankshellPath)
if strings.Contains(dankshell, "pam_fprintd") || strings.Contains(dankshell, "pam_u2f") {
t.Fatalf("lockscreen PAM should strip fingerprint and U2F modules:\n%s", dankshell)
}
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
t.Fatalf("expected dankshell-u2f to remain absent when enableU2f is false, stat err = %v", err)
}
greetd := readFileString(t, env.greetdPath)
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") {
t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd)
}
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
t.Fatalf("did not expect greetd PAM to receive U2F auth block:\n%s", greetd)
}
})
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writeSettings(t, `{"enableU2f":true,"greeterEnableFprint":true,"greeterEnableU2f":true}`)
originalGreetd := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n"
env.writePamFile(t, "greetd", originalGreetd)
var logs []string
err := syncAuthConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(true))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
t.Fatalf("expected dankshell to remain absent on NixOS path, stat err = %v", err)
}
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
t.Fatalf("expected dankshell-u2f to remain absent on NixOS path, stat err = %v", err)
}
if got := readFileString(t, env.greetdPath); got != originalGreetd {
t.Fatalf("expected greetd PAM to remain unchanged on NixOS path\ngot:\n%s\nwant:\n%s", got, originalGreetd)
}
if len(logs) < 2 || !strings.Contains(strings.Join(logs, "\n"), "NixOS detected") {
t.Fatalf("expected informational NixOS logs, got %v", logs)
}
})
}

View File

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

View File

@@ -444,21 +444,20 @@ func GetFocusedMonitor() string {
type outputInfo struct { type outputInfo struct {
x, y int32 x, y int32
scale float64
transform int32 transform int32
} }
func getAllOutputInfos() map[string]*outputInfo { func getOutputInfo(outputName string) (*outputInfo, bool) {
display, err := client.Connect("") display, err := client.Connect("")
if err != nil { if err != nil {
return nil return nil, false
} }
ctx := display.Context() ctx := display.Context()
defer ctx.Close() defer ctx.Close()
registry, err := display.GetRegistry() registry, err := display.GetRegistry()
if err != nil { if err != nil {
return nil return nil, false
} }
var outputManager *wlr_output_management.ZwlrOutputManagerV1 var outputManager *wlr_output_management.ZwlrOutputManagerV1
@@ -477,17 +476,16 @@ func getAllOutputInfos() map[string]*outputInfo {
}) })
if err := wlhelpers.Roundtrip(display, ctx); err != nil { if err := wlhelpers.Roundtrip(display, ctx); err != nil {
return nil return nil, false
} }
if outputManager == nil { if outputManager == nil {
return nil return nil, false
} }
type headState struct { type headState struct {
name string name string
x, y int32 x, y int32
scale float64
transform int32 transform int32
} }
heads := make(map[*wlr_output_management.ZwlrOutputHeadV1]*headState) heads := make(map[*wlr_output_management.ZwlrOutputHeadV1]*headState)
@@ -503,9 +501,6 @@ func getAllOutputInfos() map[string]*outputInfo {
state.x = pe.X state.x = pe.X
state.y = pe.Y state.y = pe.Y
}) })
e.Head.SetScaleHandler(func(se wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
state.scale = se.Scale
})
e.Head.SetTransformHandler(func(te wlr_output_management.ZwlrOutputHeadV1TransformEvent) { e.Head.SetTransformHandler(func(te wlr_output_management.ZwlrOutputHeadV1TransformEvent) {
state.transform = te.Transform state.transform = te.Transform
}) })
@@ -516,32 +511,21 @@ func getAllOutputInfos() map[string]*outputInfo {
for !done { for !done {
if err := ctx.Dispatch(); err != nil { if err := ctx.Dispatch(); err != nil {
return nil return nil, false
} }
} }
result := make(map[string]*outputInfo, len(heads))
for _, state := range heads { for _, state := range heads {
if state.name == "" { if state.name == outputName {
continue return &outputInfo{
} x: state.x,
result[state.name] = &outputInfo{ y: state.y,
x: state.x, transform: state.transform,
y: state.y, }, true
scale: state.scale,
transform: state.transform,
} }
} }
return result
}
func getOutputInfo(outputName string) (*outputInfo, bool) { return nil, false
infos := getAllOutputInfos()
if infos == nil {
return nil, false
}
info, ok := infos[outputName]
return info, ok
} }
func getDWLActiveWindow() (*WindowGeometry, error) { func getDWLActiveWindow() (*WindowGeometry, error) {

View File

@@ -2,7 +2,6 @@ package screenshot
import ( import (
"fmt" "fmt"
"math"
"sync" "sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -305,20 +304,22 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
if len(outputs) == 0 { if len(outputs) == 0 {
return nil, fmt.Errorf("no outputs available") return nil, fmt.Errorf("no outputs available")
} }
if len(outputs) == 1 { if len(outputs) == 1 {
return s.captureWholeOutput(outputs[0]) return s.captureWholeOutput(outputs[0])
} }
wlrInfos := getAllOutputInfos() // Capture all outputs first to get actual buffer sizes
type capturedOutput struct {
type pendingOutput struct { output *WaylandOutput
result *CaptureResult result *CaptureResult
logX float64 physX int
logY float64 physY int
scale float64
} }
var pending []pendingOutput captured := make([]capturedOutput, 0, len(outputs))
maxScale := 1.0
var minX, minY, maxX, maxY int
first := true
for _, output := range outputs { for _, output := range outputs {
result, err := s.captureWholeOutput(output) result, err := s.captureWholeOutput(output)
@@ -327,74 +328,50 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
continue continue
} }
logX, logY := float64(output.x), float64(output.y) outX, outY := output.x, output.y
scale := float64(output.scale) scale := float64(output.scale)
switch DetectCompositor() { switch DetectCompositor() {
case CompositorHyprland: case CompositorHyprland:
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok { if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
logX, logY = float64(hx), float64(hy) outX, outY = hx, hy
} }
if hs := GetHyprlandMonitorScale(output.name); hs > 0 { if s := GetHyprlandMonitorScale(output.name); s > 0 {
scale = hs scale = s
} }
default: case CompositorDWL:
if wlrInfos != nil { if info, ok := getOutputInfo(output.name); ok {
if info, ok := wlrInfos[output.name]; ok { outX, outY = info.x, info.y
logX, logY = float64(info.x), float64(info.y)
if info.scale > 0 {
scale = info.scale
}
}
} }
} }
if scale <= 0 { if scale <= 0 {
scale = 1.0 scale = 1.0
} }
pending = append(pending, pendingOutput{result: result, logX: logX, logY: logY, scale: scale}) physX := int(float64(outX) * scale)
if scale > maxScale { physY := int(float64(outY) * scale)
maxScale = scale
}
}
if len(pending) == 0 { captured = append(captured, capturedOutput{
return nil, fmt.Errorf("failed to capture any outputs") output: output,
} result: result,
if len(pending) == 1 { physX: physX,
return pending[0].result, nil physY: physY,
} })
type layoutEntry struct { right := physX + result.Buffer.Width
result *CaptureResult bottom := physY + result.Buffer.Height
canvasX int
canvasY int
canvasW int
canvasH int
}
entries := make([]layoutEntry, len(pending))
var minX, minY, maxX, maxY int
for i, p := range pending { if first {
cx := int(math.Round(p.logX * maxScale)) minX, minY = physX, physY
cy := int(math.Round(p.logY * maxScale)) maxX, maxY = right, bottom
cw := int(math.Round(float64(p.result.Buffer.Width) * maxScale / p.scale)) first = false
ch := int(math.Round(float64(p.result.Buffer.Height) * maxScale / p.scale))
entries[i] = layoutEntry{result: p.result, canvasX: cx, canvasY: cy, canvasW: cw, canvasH: ch}
right := cx + cw
bottom := cy + ch
if i == 0 {
minX, minY, maxX, maxY = cx, cy, right, bottom
continue continue
} }
if cx < minX {
minX = cx if physX < minX {
minX = physX
} }
if cy < minY { if physY < minY {
minY = cy minY = physY
} }
if right > maxX { if right > maxX {
maxX = right maxX = right
@@ -404,26 +381,35 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
} }
} }
if len(captured) == 0 {
return nil, fmt.Errorf("failed to capture any outputs")
}
if len(captured) == 1 {
return captured[0].result, nil
}
totalW := maxX - minX totalW := maxX - minX
totalH := maxY - minY totalH := maxY - minY
composite, err := CreateShmBuffer(totalW, totalH, totalW*4)
compositeStride := totalW * 4
composite, err := CreateShmBuffer(totalW, totalH, compositeStride)
if err != nil { if err != nil {
for _, e := range entries { for _, c := range captured {
e.result.Buffer.Close() c.result.Buffer.Close()
} }
return nil, fmt.Errorf("create composite buffer: %w", err) return nil, fmt.Errorf("create composite buffer: %w", err)
} }
composite.Clear() composite.Clear()
var format uint32 var format uint32
for _, e := range entries { for _, c := range captured {
if format == 0 { if format == 0 {
format = e.result.Format format = c.result.Format
} }
s.blitBufferScaled(composite, e.result.Buffer, s.blitBuffer(composite, c.result.Buffer, c.physX-minX, c.physY-minY, c.result.YInverted)
e.canvasX-minX, e.canvasY-minY, e.canvasW, e.canvasH, c.result.Buffer.Close()
e.result.YInverted)
e.result.Buffer.Close()
} }
return &CaptureResult{ return &CaptureResult{
@@ -433,44 +419,32 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
}, nil }, nil
} }
func (s *Screenshoter) blitBufferScaled(dst, src *ShmBuffer, dstX, dstY, dstW, dstH int, yInverted bool) { func (s *Screenshoter) blitBuffer(dst, src *ShmBuffer, dstX, dstY int, yInverted bool) {
if dstW <= 0 || dstH <= 0 {
return
}
srcData := src.Data() srcData := src.Data()
dstData := dst.Data() dstData := dst.Data()
for dy := 0; dy < dstH; dy++ { for srcY := 0; srcY < src.Height; srcY++ {
canvasY := dstY + dy actualSrcY := srcY
if canvasY < 0 || canvasY >= dst.Height {
continue
}
srcY := dy * src.Height / dstH
if yInverted { if yInverted {
srcY = src.Height - 1 - srcY actualSrcY = src.Height - 1 - srcY
} }
if srcY < 0 || srcY >= src.Height {
dy := dstY + srcY
if dy < 0 || dy >= dst.Height {
continue continue
} }
srcRowOff := srcY * src.Stride srcRowOff := actualSrcY * src.Stride
dstRowOff := canvasY * dst.Stride dstRowOff := dy * dst.Stride
for dx := 0; dx < dstW; dx++ { for srcX := 0; srcX < src.Width; srcX++ {
canvasX := dstX + dx dx := dstX + srcX
if canvasX < 0 || canvasX >= dst.Width { if dx < 0 || dx >= dst.Width {
continue
}
srcX := dx * src.Width / dstW
if srcX >= src.Width {
continue continue
} }
si := srcRowOff + srcX*4 si := srcRowOff + srcX*4
di := dstRowOff + canvasX*4 di := dstRowOff + dx*4
if si+3 >= len(srcData) || di+3 >= len(dstData) { if si+3 >= len(srcData) || di+3 >= len(dstData) {
continue continue

View File

@@ -215,34 +215,31 @@ func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential
callback: callback, callback: callback,
} }
if existing, exists := b.debounceTimers[id]; exists { if timer, exists := b.debounceTimers[id]; exists {
if existing.Stop() { timer.Reset(200 * time.Millisecond)
b.debounceWg.Done() } 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()
}
})
} }
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() b.debounceMutex.Unlock()
return nil return nil
@@ -493,19 +490,5 @@ func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
return percent 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() { func (b *DDCBackend) Close() {
} }

View File

@@ -84,7 +84,6 @@ type DDCBackend struct {
debounceMutex sync.Mutex debounceMutex sync.Mutex
debounceTimers map[string]*time.Timer debounceTimers map[string]*time.Timer
debouncePending map[string]ddcPendingSet debouncePending map[string]ddcPendingSet
debounceWg sync.WaitGroup
} }
type ddcPendingSet struct { type ddcPendingSet struct {

View File

@@ -212,10 +212,9 @@ func (m *Manager) setupDataDeviceSync() {
} }
var offer any var offer any
switch { if e.Id != nil {
case e.Id != nil:
offer = e.Id offer = e.Id
case e.OfferId != 0: } else if e.OfferId != 0 {
m.offerMutex.RLock() m.offerMutex.RLock()
offer = m.offerRegistry[e.OfferId] offer = m.offerRegistry[e.OfferId]
m.offerMutex.RUnlock() m.offerMutex.RUnlock()
@@ -225,6 +224,10 @@ func (m *Manager) setupDataDeviceSync() {
wasOwner := m.isOwner wasOwner := m.isOwner
m.ownerLock.Unlock() m.ownerLock.Unlock()
if offer == nil {
return
}
if wasOwner { if wasOwner {
return return
} }
@@ -233,11 +236,9 @@ func (m *Manager) setupDataDeviceSync() {
m.currentOffer = offer m.currentOffer = offer
if prevOffer != nil && prevOffer != offer { if prevOffer != nil && prevOffer != offer {
m.releaseOffer(prevOffer) m.offerMutex.Lock()
} delete(m.offerMimeTypes, prevOffer)
m.offerMutex.Unlock()
if offer == nil {
return
} }
m.offerMutex.RLock() m.offerMutex.RLock()
@@ -291,33 +292,6 @@ func (m *Manager) setupDataDeviceSync() {
log.Info("Data device setup complete") log.Info("Data device setup complete")
} }
func (m *Manager) releaseOffer(offer any) {
if offer == nil {
return
}
typedOffer, ok := offer.(*ext_data_control.ExtDataControlOfferV1)
if !ok {
return
}
m.offerMutex.Lock()
delete(m.offerMimeTypes, offer)
delete(m.offerRegistry, typedOffer.ID())
m.offerMutex.Unlock()
typedOffer.Destroy()
}
func (m *Manager) releaseCurrentSource() {
if m.currentSource == nil {
return
}
source, ok := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
m.currentSource = nil
if !ok {
return
}
source.Destroy()
}
func (m *Manager) readAndStore(r *os.File, mimeType string) { func (m *Manager) readAndStore(r *os.File, mimeType string) {
defer r.Close() defer r.Close()
@@ -421,7 +395,7 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
if extractHash(v) != hash { if extractHash(v) != hash {
continue continue
} }
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
continue continue
} }
@@ -439,7 +413,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
c := b.Cursor() c := b.Cursor()
var count int var count int
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
continue continue
} }
@@ -482,14 +456,6 @@ func encodeEntry(e Entry) ([]byte, error) {
} }
func decodeEntry(data []byte) (Entry, error) { func decodeEntry(data []byte) (Entry, error) {
return decodeEntryFields(data, true)
}
func decodeEntryMeta(data []byte) (Entry, error) {
return decodeEntryFields(data, false)
}
func decodeEntryFields(data []byte, withData bool) (Entry, error) {
buf := bytes.NewReader(data) buf := bytes.NewReader(data)
var e Entry var e Entry
@@ -497,15 +463,8 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
var dataLen uint32 var dataLen uint32
binary.Read(buf, binary.BigEndian, &dataLen) binary.Read(buf, binary.BigEndian, &dataLen)
switch { e.Data = make([]byte, dataLen)
case withData: buf.Read(e.Data)
e.Data = make([]byte, dataLen)
buf.Read(e.Data)
default:
if _, err := buf.Seek(int64(dataLen), io.SeekCurrent); err != nil {
return e, err
}
}
var mimeLen uint32 var mimeLen uint32
binary.Read(buf, binary.BigEndian, &mimeLen) binary.Read(buf, binary.BigEndian, &mimeLen)
@@ -709,9 +668,14 @@ func sizeStr(size int) string {
func (m *Manager) updateState() { func (m *Manager) updateState() {
history := m.GetHistory() history := m.GetHistory()
for i := range history {
history[i].Data = nil
}
var current *Entry var current *Entry
if len(history) > 0 { if len(history) > 0 {
c := history[0] c := history[0]
c.Data = nil
current = &c current = &c
} }
@@ -786,7 +750,7 @@ func (m *Manager) GetHistory() []Entry {
c := b.Cursor() c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err != nil { if err != nil {
continue continue
} }
@@ -971,7 +935,7 @@ func (m *Manager) ClearHistory() {
var toDelete [][]byte var toDelete [][]byte
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err != nil || !entry.Pinned { if err != nil || !entry.Pinned {
toDelete = append(toDelete, k) toDelete = append(toDelete, k)
} }
@@ -994,7 +958,7 @@ func (m *Manager) ClearHistory() {
if b != nil { if b != nil {
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, _ := decodeEntryMeta(v) entry, _ := decodeEntry(v)
if entry.Pinned { if entry.Pinned {
pinnedCount++ pinnedCount++
} }
@@ -1102,7 +1066,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
m.ownerLock.Unlock() m.ownerLock.Unlock()
}) })
m.releaseCurrentSource()
m.currentSource = source m.currentSource = source
m.sourceMutex.Lock() m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType} m.sourceMimeTypes = []string{mimeType}
@@ -1182,11 +1145,9 @@ func (m *Manager) Close() {
m.subscribers = make(map[string]chan State) m.subscribers = make(map[string]chan State)
m.subMutex.Unlock() m.subMutex.Unlock()
m.releaseCurrentSource() if m.currentSource != nil {
source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1)
if m.currentOffer != nil { source.Destroy()
m.releaseOffer(m.currentOffer)
m.currentOffer = nil
} }
if m.dataDevice != nil { if m.dataDevice != nil {
@@ -1230,10 +1191,11 @@ func (m *Manager) clearOldEntries(days int) error {
var toDelete [][]byte var toDelete [][]byte
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err != nil { if err != nil {
continue continue
} }
// Skip pinned entries
if entry.Pinned { if entry.Pinned {
continue continue
} }
@@ -1348,7 +1310,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
c := b.Cursor() c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err != nil { if err != nil {
continue continue
} }
@@ -1373,6 +1335,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
continue continue
} }
entry.Data = nil
all = append(all, entry) all = append(all, entry)
} }
return nil return nil
@@ -1547,7 +1510,7 @@ func (m *Manager) PinEntry(id uint64) error {
} }
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err != nil || !entry.Pinned { if err != nil || !entry.Pinned {
continue continue
} }
@@ -1565,6 +1528,7 @@ func (m *Manager) PinEntry(id uint64) error {
return nil return nil
} }
// Check pinned count
cfg := m.getConfig() cfg := m.getConfig()
pinnedCount := 0 pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error { if err := m.db.View(func(tx *bolt.Tx) error {
@@ -1574,7 +1538,7 @@ func (m *Manager) PinEntry(id uint64) error {
} }
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
pinnedCount++ pinnedCount++
} }
@@ -1665,11 +1629,12 @@ func (m *Manager) GetPinnedEntries() []Entry {
c := b.Cursor() c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err != nil { if err != nil {
continue continue
} }
if entry.Pinned { if entry.Pinned {
entry.Data = nil
pinned = append(pinned, entry) pinned = append(pinned, entry)
} }
} }
@@ -1695,7 +1660,7 @@ func (m *Manager) GetPinnedCount() int {
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntryMeta(v) entry, err := decodeEntry(v)
if err == nil && entry.Pinned { if err == nil && entry.Pinned {
count++ count++
} }
@@ -1814,7 +1779,6 @@ func (m *Manager) CopyFile(filePath string) error {
m.ownerLock.Unlock() m.ownerLock.Unlock()
}) })
m.releaseCurrentSource()
m.currentSource = source m.currentSource = source
m.ownerLock.Lock() m.ownerLock.Lock()

View File

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

View File

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

View File

@@ -35,7 +35,12 @@ type SessionState struct {
type EventType string type EventType string
const ( const (
EventStateChanged EventType = "state_changed" EventStateChanged EventType = "state_changed"
EventLock EventType = "lock"
EventUnlock EventType = "unlock"
EventPrepareForSleep EventType = "prepare_for_sleep"
EventIdleHintChanged EventType = "idle_hint_changed"
EventLockedHintChanged EventType = "locked_hint_changed"
) )
type SessionEvent struct { type SessionEvent struct {

View File

@@ -8,6 +8,11 @@ import (
func TestEventType_Constants(t *testing.T) { func TestEventType_Constants(t *testing.T) {
assert.Equal(t, EventType("state_changed"), EventStateChanged) assert.Equal(t, EventType("state_changed"), EventStateChanged)
assert.Equal(t, EventType("lock"), EventLock)
assert.Equal(t, EventType("unlock"), EventUnlock)
assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
} }
func TestSessionState_Struct(t *testing.T) { func TestSessionState_Struct(t *testing.T) {
@@ -35,11 +40,11 @@ func TestSessionEvent_Struct(t *testing.T) {
} }
event := SessionEvent{ event := SessionEvent{
Type: EventStateChanged, Type: EventLock,
Data: state, Data: state,
} }
assert.Equal(t, EventStateChanged, event.Type) assert.Equal(t, EventLock, event.Type)
assert.Equal(t, "1", event.Data.SessionID) assert.Equal(t, "1", event.Data.SessionID)
assert.True(t, event.Data.Locked) assert.True(t, event.Data.Locked)
} }

View File

@@ -158,26 +158,18 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo
channel := frequencyToChannel(freq) 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{ network := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
Signal: strength, Signal: strength,
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: isConnected, Connected: ssid == currentSSID && bssid == currentBSSID,
Saved: savedSSIDs[ssid], Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid], Autoconnect: autoconnectMap[ssid],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: rate, Rate: maxBitrate / 1000,
Channel: channel, Channel: channel,
} }
@@ -522,27 +514,19 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
channel := frequencyToChannel(freq) 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{ network := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
Signal: strength, Signal: strength,
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: isConnected, Connected: ssid == currentSSID,
Saved: savedSSIDs[ssid], Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid], Autoconnect: autoconnectMap[ssid],
Hidden: hiddenSSIDs[ssid], Hidden: hiddenSSIDs[ssid],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: rate, Rate: maxBitrate / 1000,
Channel: channel, Channel: channel,
} }
@@ -1078,27 +1062,19 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
channel := frequencyToChannel(freq) 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{ network := WiFiNetwork{
SSID: apSSID, SSID: apSSID,
BSSID: apBSSID, BSSID: apBSSID,
Signal: strength, Signal: strength,
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: isConnected, Connected: connected && apSSID == ssid,
Saved: savedSSIDs[apSSID], Saved: savedSSIDs[apSSID],
Autoconnect: autoconnectMap[apSSID], Autoconnect: autoconnectMap[apSSID],
Hidden: hiddenSSIDs[apSSID], Hidden: hiddenSSIDs[apSSID],
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: rate, Rate: maxBitrate / 1000,
Channel: channel, Channel: channel,
Device: name, Device: name,
} }

View File

@@ -20,7 +20,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -203,15 +202,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "sysupdate.") {
if sysUpdateManager == nil {
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
return
}
sysupdate.HandleRequest(conn, req, sysUpdateManager)
return
}
switch req.Method { switch req.Method {
case "ping": case "ping":
models.Respond(conn, req.ID, "pong") models.Respond(conn, req.ID, "pong")

View File

@@ -30,9 +30,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "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/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -74,9 +72,7 @@ var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager var themeModeManager *thememode.Manager
var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager
var geoClientInstance geolocation.Client var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client" const dbusClientID = "dms-dbus-client"
@@ -398,18 +394,6 @@ func InitializeThemeModeManager() error {
return nil 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 { func InitializeLocationManager(geoClient geolocation.Client) error {
manager, err := location.NewManager(geoClient) manager, err := location.NewManager(geoClient)
if err != nil { if err != nil {
@@ -423,19 +407,6 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
return nil return nil
} }
func InitializeSysUpdateManager() error {
manager, err := sysupdate.NewManager()
if err != nil {
log.Warnf("Failed to initialize sysupdate manager: %v", err)
return err
}
sysUpdateManager = manager
log.Info("Sysupdate manager initialized")
return nil
}
func handleConnection(conn net.Conn) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
@@ -521,10 +492,6 @@ func getCapabilities() Capabilities {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return Capabilities{Capabilities: caps} return Capabilities{Capabilities: caps}
} }
@@ -595,10 +562,6 @@ func getServerInfo() ServerInfo {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
if sysUpdateManager != nil {
caps = append(caps, "sysupdate")
}
return ServerInfo{ return ServerInfo{
APIVersion: APIVersion, APIVersion: APIVersion,
CLIVersion: CLIVersion, CLIVersion: CLIVersion,
@@ -1266,38 +1229,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}() }()
} }
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
wg.Add(1)
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
go func() {
defer wg.Done()
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
initialState := sysUpdateManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-sysupdateChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("dbus") && dbusManager != nil { if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1) wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID) dbusChan := dbusManager.SubscribeSignals(dbusClientID)
@@ -1394,18 +1325,12 @@ func cleanupManagers() {
if themeModeManager != nil { if themeModeManager != nil {
themeModeManager.Close() themeModeManager.Close()
} }
if trayRecoveryManager != nil {
trayRecoveryManager.Close()
}
if wlContext != nil { if wlContext != nil {
wlContext.Close() wlContext.Close()
} }
if locationManager != nil { if locationManager != nil {
locationManager.Close() locationManager.Close()
} }
if sysUpdateManager != nil {
sysUpdateManager.Close()
}
if geoClientInstance != nil { if geoClientInstance != nil {
geoClientInstance.Close() geoClientInstance.Close()
} }
@@ -1685,18 +1610,6 @@ 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() { go func() {
geoClient := geolocation.NewClient() geoClient := geolocation.NewClient()
geoClientInstance = geoClient geoClientInstance = geoClient
@@ -1791,10 +1704,6 @@ func Start(printDocs bool) error {
} }
}() }()
if err := InitializeSysUpdateManager(); err != nil {
log.Warnf("Sysupdate manager unavailable: %v", err)
}
log.Info("") log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities) log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)

View File

@@ -1,96 +0,0 @@
package sysupdate
import (
"context"
"os/exec"
"sync"
)
type Backend interface {
ID() string
DisplayName() string
Repo() RepoKind
IsAvailable(ctx context.Context) bool
NeedsAuth() bool
RunsInTerminal() bool
CheckUpdates(ctx context.Context) ([]Package, error)
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
}
type Selection struct {
System Backend
Overlay []Backend
}
func (s Selection) All() []Backend {
if s.System == nil {
return s.Overlay
}
out := make([]Backend, 0, 1+len(s.Overlay))
out = append(out, s.System)
out = append(out, s.Overlay...)
return out
}
func (s Selection) Info() []BackendInfo {
all := s.All()
out := make([]BackendInfo, 0, len(all))
for _, b := range all {
out = append(out, BackendInfo{
ID: b.ID(),
DisplayName: b.DisplayName(),
Repo: b.Repo(),
NeedsAuth: b.NeedsAuth(),
RunsInTerminal: b.RunsInTerminal(),
})
}
return out
}
var (
registryMu sync.RWMutex
systemCandidates []func() Backend
overlayCandidate []func() Backend
)
func RegisterSystemBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
systemCandidates = append(systemCandidates, factory)
}
func RegisterOverlayBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
overlayCandidate = append(overlayCandidate, factory)
}
func Select(ctx context.Context) Selection {
registryMu.RLock()
sys := append([]func() Backend(nil), systemCandidates...)
ov := append([]func() Backend(nil), overlayCandidate...)
registryMu.RUnlock()
var sel Selection
for _, factory := range sys {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.System = b
break
}
for _, factory := range ov {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.Overlay = append(sel.Overlay, b)
}
return sel
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -1,79 +0,0 @@
package sysupdate
import (
"context"
"os/exec"
"regexp"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &aptBackend{} })
}
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
type aptBackend struct{}
func (aptBackend) ID() string { return "apt" }
func (aptBackend) DisplayName() string { return "APT" }
func (aptBackend) Repo() RepoKind { return RepoSystem }
func (aptBackend) NeedsAuth() bool { return true }
func (aptBackend) RunsInTerminal() bool { return false }
func (aptBackend) IsAvailable(_ context.Context) bool {
return commandExists("apt") || commandExists("apt-get")
}
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
out, err := cmd.Output()
if err != nil {
return nil, err
}
return parseAptUpgradable(string(out)), nil
}
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
bin := "apt-get"
if !commandExists(bin) {
bin = "apt"
}
if opts.DryRun {
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
OnLine: onLine,
})
}
names := pickTargetNames(opts.Targets, "apt", true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func parseAptUpgradable(text string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := aptUpgradableLine.FindStringSubmatch(line)
if m == nil {
continue
}
pkgs = append(pkgs, Package{
Name: m[1],
Repo: RepoSystem,
Backend: "apt",
FromVersion: m[3],
ToVersion: m[2],
})
}
return pkgs
}

View File

@@ -1,72 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseAptUpgradable(t *testing.T) {
tests := []struct {
name string
input string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "header line only",
input: `Listing... Done
`,
want: nil,
},
{
name: "single upgradable",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
{
name: "multiple architectures and suites",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "package name with hyphens, dots, plus signs",
input: `Listing... Done
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
want: []Package{
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
},
},
{
name: "non-matching lines ignored",
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseAptUpgradable(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,125 +0,0 @@
package sysupdate
import (
"context"
"errors"
"os/exec"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
}
type dnfBackend struct {
bin string
}
func (b dnfBackend) ID() string { return b.bin }
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
func (b dnfBackend) NeedsAuth() bool { return true }
func (b dnfBackend) RunsInTerminal() bool { return false }
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
if !commandExists(b.bin) {
return false
}
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
return false
}
return true
}
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := dnfListUpgrades(ctx, b.bin)
if err != nil {
return nil, err
}
installed := rpmInstalledVersions(ctx)
return parseDnfList(out, b.bin, installed), nil
}
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.bin, true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", b.bin, "upgrade", "-y"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--quiet")
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
return "", nil
}
return "", err
}
func rpmInstalledVersions(ctx context.Context) map[string]string {
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
if err != nil {
return nil
}
m := make(map[string]string)
for line := range strings.SplitSeq(string(out), "\n") {
name, ver, ok := strings.Cut(line, "\t")
if !ok {
continue
}
m[name] = ver
}
return m
}
func parseDnfList(text, backendID string, installed map[string]string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
nameArch := fields[0]
version := fields[1]
dot := strings.LastIndex(nameArch, ".")
if dot <= 0 {
continue
}
if !looksLikeRpmVersion(version) {
continue
}
name := nameArch[:dot]
pkgs = append(pkgs, Package{
Name: nameArch,
Repo: RepoSystem,
Backend: backendID,
FromVersion: installed[name],
ToVersion: version,
})
}
return pkgs
}
func looksLikeRpmVersion(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r >= '0' && r <= '9' {
return true
}
}
return false
}

View File

@@ -1,80 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseDnfList(t *testing.T) {
tests := []struct {
name string
input string
backendID string
installed map[string]string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "single package with installed cross-ref",
input: "bash.x86_64 5.2.40-1.fc41 updates",
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "noarch package and missing installed entry",
input: `bash.x86_64 5.2.40-1.fc41 updates
fonts-misc.noarch 1.0.5-2.fc41 updates`,
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
},
},
{
name: "skips header rows",
input: `Available
Upgrades
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "skips lines with too few fields",
input: "incomplete",
backendID: "dnf",
want: nil,
},
{
name: "skips dnf5 banner / column header lines",
input: `Updates available
Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026.
Package Version Repository Size
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDnfList(tt.input, tt.backendID, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,169 +0,0 @@
package sysupdate
import (
"context"
"os/exec"
"strings"
)
func init() {
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
}
type flatpakBackend struct{}
func (flatpakBackend) ID() string { return "flatpak" }
func (flatpakBackend) DisplayName() string { return "Flatpak" }
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
func (flatpakBackend) NeedsAuth() bool { return false }
func (flatpakBackend) RunsInTerminal() bool { return false }
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
out, err := cmd.Output()
if err != nil {
return nil, err
}
installed := flatpakInstalled(ctx)
return parseFlatpakUpdates(string(out), installed), nil
}
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output()
if err != nil {
return nil
}
m := make(map[string]flatpakInstalledEntry)
for line := range strings.SplitSeq(string(out), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
entry := flatpakInstalledEntry{}
if len(fields) > 1 {
entry.version = fields[1]
}
if len(fields) > 2 {
entry.branch = fields[2]
}
if len(fields) > 3 {
entry.commit = fields[3]
}
key := appID
if entry.branch != "" {
key = appID + "//" + entry.branch
}
m[key] = entry
}
return m
}
type flatpakInstalledEntry struct {
version string
branch string
commit string
}
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
}
refs := flatpakTargetRefs(opts.Targets)
if len(refs) == 0 {
return nil
}
argv := append([]string{"flatpak", "update", "-y", "--noninteractive"}, refs...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func flatpakTargetRefs(targets []Package) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != "flatpak" {
continue
}
ref := p.Ref
if ref == "" {
ref = p.Name
}
out = append(out, ref)
}
return out
}
func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
version, branch, commit := "", "", ""
if len(fields) > 1 {
version = fields[1]
}
if len(fields) > 2 {
branch = fields[2]
}
if len(fields) > 3 {
commit = fields[3]
}
display := appID
if len(fields) > 4 && fields[4] != "" {
display = fields[4]
}
key := appID
if branch != "" {
key = appID + "//" + branch
}
inst := installed[key]
if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) {
continue
}
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
ref := appID
if branch != "" {
ref = appID + "//" + branch
}
pkgs = append(pkgs, Package{
Name: display,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
ToVersion: to,
Ref: ref,
})
}
return pkgs
}
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
if remoteVer != "" {
return installedVer, remoteVer
}
return shortCommit(installedCommit), shortCommit(remoteCommit)
}
func shortCommit(c string) string {
if len(c) > 8 {
return c[:8]
}
return c
}

View File

@@ -1,150 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseFlatpakUpdates(t *testing.T) {
tests := []struct {
name string
input string
installed map[string]flatpakInstalledEntry
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "real flathub-style row with empty version, falls back to commit",
// columns: application,version,branch,commit,name
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
installed: map[string]flatpakInstalledEntry{
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
},
want: []Package{
{
Name: "Discord",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "8b16fa1a",
ToVersion: "43a1e5d2",
Ref: "com.discordapp.Discord//stable",
},
},
},
{
name: "remote provides version, installed version known",
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
installed: map[string]flatpakInstalledEntry{
"com.example.App//stable": {version: "1.4.2"},
},
want: []Package{
{
Name: "Example App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "1.4.2",
ToVersion: "1.5.0",
Ref: "com.example.App//stable",
},
},
},
{
name: "no installed entry, remote has no version, falls back to commit on both sides",
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
installed: nil,
want: []Package{
{
Name: "gnome platform",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "badcd4af",
Ref: "org.gnome.Platform//49",
},
},
},
{
name: "missing display name falls back to application id",
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
want: []Package{
{
Name: "com.example.NoName",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "2.0",
Ref: "com.example.NoName//stable",
},
},
},
{
name: "skips blank lines and rows with empty application id",
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
want: []Package{
{
Name: "Real App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "1.0",
Ref: "org.real.App//stable",
},
},
},
{
name: "skips phantom updates where remote commit matches installed",
input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom",
installed: map[string]flatpakInstalledEntry{
"com.phantom.App//stable": {commit: "abc12345"},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlatpakUpdates(tt.input, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}
func TestFlatpakVersionPair(t *testing.T) {
tests := []struct {
name string
installedVer, installedCommit, remoteVer, remoteCommit string
wantFrom, wantTo string
}{
{
name: "remote has version - prefer versions",
installedVer: "1.0.0", remoteVer: "1.1.0",
wantFrom: "1.0.0", wantTo: "1.1.0",
},
{
name: "remote has no version - both sides fall to short commit",
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
},
{
name: "short commits left as-is",
installedCommit: "abc123", remoteCommit: "def456",
wantFrom: "abc123", wantTo: "def456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
if from != tt.wantFrom || to != tt.wantTo {
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
}
})
}
}

View File

@@ -1,258 +0,0 @@
package sysupdate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func init() {
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
}
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
type pacmanBackend struct{}
func (pacmanBackend) ID() string { return "pacman" }
func (pacmanBackend) DisplayName() string { return "Pacman" }
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
func (pacmanBackend) NeedsAuth() bool { return true }
func (pacmanBackend) RunsInTerminal() bool { return false }
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
return parseArchUpdates(out, b.ID(), RepoSystem), nil
}
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.ID(), opts.IncludeAUR)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "pacman", "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
type archHelperBackend struct {
id string
}
func (b archHelperBackend) ID() string { return b.id }
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
func (b archHelperBackend) NeedsAuth() bool { return true }
func (b archHelperBackend) RunsInTerminal() bool {
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
}
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
func (b archHelperBackend) DisplayName() string {
switch b.id {
case "paru":
return "Paru (AUR)"
case "yay":
return "Yay (AUR)"
default:
return b.id
}
}
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
repoOut, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
if err != nil {
return nil, err
}
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
return pkgs, nil
}
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, b.id, opts.IncludeAUR)
if len(names) == 0 {
return nil
}
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
argv := append([]string{"pkexec", b.id, "-Sy", "--noconfirm", "--needed"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
cmd := fmt.Sprintf("%s -Sy --noconfirm --needed %s", b.id, strings.Join(names, " "))
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
}
func pickTargetNames(targets []Package, backendID string, includeAUR bool) []string {
out := make([]string, 0, len(targets))
for _, p := range targets {
if p.Backend != backendID {
continue
}
if !includeAUR && p.Repo == RepoAUR {
continue
}
out = append(out, p.Name)
}
return out
}
func pacmanRepoUpdates(ctx context.Context) (string, error) {
if commandExists("checkupdates") {
return capturePermissive(ctx, "checkupdates")
}
if commandExists("fakeroot") {
out, err := pacmanCheckViaFakeroot(ctx)
if err == nil {
return out, nil
}
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
}
return capturePermissive(ctx, "pacman", "-Qu")
}
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
dir, err := pacmanPrivateDB()
if err != nil {
return "", err
}
if err := seedPacmanDB(dir); err != nil {
return "", fmt.Errorf("seed sync db: %w", err)
}
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
if out, err := refresh.CombinedOutput(); err != nil {
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
}
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
}
func seedPacmanDB(dir string) error {
syncDir := filepath.Join(dir, "sync")
if err := os.MkdirAll(syncDir, 0o755); err != nil {
return err
}
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
if err != nil {
return err
}
for _, src := range dbs {
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
return err
}
}
localLink := filepath.Join(dir, "local")
if fi, err := os.Lstat(localLink); err == nil {
if fi.Mode()&os.ModeSymlink == 0 {
if err := os.RemoveAll(localLink); err != nil {
return err
}
} else {
return nil
}
}
return os.Symlink("/var/lib/pacman/local", localLink)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func pacmanPrivateDB() (string, error) {
tmp := os.Getenv("TMPDIR")
if tmp == "" {
tmp = "/tmp"
}
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 1, 2:
return string(out), nil
}
}
return "", err
}
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := archUpdateLine.FindStringSubmatch(line)
if m == nil {
continue
}
p := Package{
Name: m[1],
Repo: repo,
Backend: backendID,
FromVersion: m[2],
ToVersion: m[3],
}
if repo == RepoAUR {
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}

View File

@@ -1,114 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseArchUpdates(t *testing.T) {
tests := []struct {
name string
input string
backendID string
repo RepoKind
want []Package
}{
{
name: "empty",
input: "",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "whitespace only",
input: " \n\n \n",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "single repo update",
input: "bat 0.26.0-1 -> 0.26.1-2",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "multiple updates with epoch versions",
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
linux 6.18.0-1 -> 6.18.1-1
mesa 26.4.0-1 -> 26.4.1-1`,
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
},
},
{
name: "AUR update with changelog url",
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
backendID: "paru",
repo: RepoAUR,
want: []Package{
{
Name: "google-chrome",
Repo: RepoAUR,
Backend: "paru",
FromVersion: "147.0.7727.116-1",
ToVersion: "147.0.7727.137-1",
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
},
},
},
{
name: "git package latest-commit marker",
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
backendID: "yay",
repo: RepoAUR,
want: []Package{
{
Name: "niri-git",
Repo: RepoAUR,
Backend: "yay",
FromVersion: "26.04.r5.ga85b922-1",
ToVersion: "latest-commit",
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
},
},
},
{
name: "skips lines that don't match arrow format",
input: `bat 0.26.0-1 -> 0.26.1-2
this is not an update line
foo`,
backendID: "pacman",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "extra whitespace tolerated",
input: " bat 0.26.0-1 -> 0.26.1-2 ",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,125 +0,0 @@
package sysupdate
import (
"context"
"encoding/json"
"errors"
"os/exec"
)
const ostreeExitUpdateAvailable = 77
func init() {
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
}
type rpmOstreeBackend struct{}
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
func (rpmOstreeBackend) NeedsAuth() bool { return true }
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
if !commandExists("rpm-ostree") {
return false
}
return ostreeBooted(ctx)
}
type ostreeStatus struct {
Deployments []ostreeDeployment `json:"deployments"`
CachedUpdate *ostreeCached `json:"cached-update"`
}
type ostreeDeployment struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Booted bool `json:"booted"`
}
type ostreeCached struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Checksum string `json:"checksum"`
}
func ostreeBooted(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
out, err := cmd.Output()
if err != nil {
return false
}
var s ostreeStatus
if err := json.Unmarshal(out, &s); err != nil {
return false
}
return len(s.Deployments) > 0
}
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
if err := cmd.Run(); err != nil {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
return nil, err
}
}
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
if err != nil {
return nil, err
}
return parseRpmOstreeStatus(statusOut)
}
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
var s ostreeStatus
if err := json.Unmarshal(statusOut, &s); err != nil {
return nil, err
}
if s.CachedUpdate == nil {
return nil, nil
}
booted := bootedDeployment(s.Deployments)
from := ""
if booted != nil {
from = booted.Version
}
if from == s.CachedUpdate.Version {
return nil, nil
}
name := s.CachedUpdate.Origin
if name == "" {
name = "system"
}
return []Package{{
Name: name,
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: from,
ToVersion: s.CachedUpdate.Version,
}}, nil
}
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
for i := range deps {
if deps[i].Booted {
return &deps[i]
}
}
return nil
}
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
argv := []string{"rpm-ostree", "upgrade"}
if opts.DryRun {
argv = append(argv, "--check")
}
return Run(ctx, argv, RunOptions{OnLine: onLine})
}

View File

@@ -1,104 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseRpmOstreeStatus(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "no cached update",
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
want: nil,
},
{
name: "cached update available, booted version differs",
input: `{
"deployments": [
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
],
"cached-update": {
"origin": "fedora:fedora/x86_64/silverblue",
"version": "39.20240115.0",
"checksum": "abc123"
}
}`,
want: []Package{
{
Name: "fedora:fedora/x86_64/silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "39.20240101.0",
ToVersion: "39.20240115.0",
},
},
},
{
name: "cached update equals booted version (no real update)",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": true}],
"cached-update": {"origin": "x", "version": "39.20240101.0"}
}`,
want: nil,
},
{
name: "no booted deployment falls back to empty from",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": false}],
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
}`,
want: []Package{
{
Name: "fedora:silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "",
ToVersion: "39.20240115.0",
},
},
},
{
name: "missing origin defaults to system",
input: `{
"deployments": [{"version": "1.0", "booted": true}],
"cached-update": {"version": "1.1"}
}`,
want: []Package{
{
Name: "system",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "1.0",
ToVersion: "1.1",
},
},
},
{
name: "malformed JSON",
input: `{not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseRpmOstreeStatus([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,83 +0,0 @@
package sysupdate
import (
"context"
"encoding/xml"
"errors"
"os/exec"
)
func init() {
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
}
type zypperBackend struct{}
func (zypperBackend) ID() string { return "zypper" }
func (zypperBackend) DisplayName() string { return "Zypper" }
func (zypperBackend) Repo() RepoKind { return RepoSystem }
func (zypperBackend) NeedsAuth() bool { return true }
func (zypperBackend) RunsInTerminal() bool { return false }
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
type zypperUpdateList struct {
XMLName xml.Name `xml:"stream"`
Updates []zypperUpdate `xml:"update-list>update"`
}
type zypperUpdate struct {
Name string `xml:"name,attr"`
Edition string `xml:"edition,attr"`
EditionOld string `xml:"edition-old,attr"`
Kind string `xml:"kind,attr"`
}
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
out, err := cmd.Output()
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 100, 101, 102, 103:
err = nil
}
}
if err != nil {
return nil, err
}
}
return parseZypperXML(out)
}
func parseZypperXML(out []byte) ([]Package, error) {
var list zypperUpdateList
if err := xml.Unmarshal(out, &list); err != nil {
return nil, err
}
pkgs := make([]Package, 0, len(list.Updates))
for _, u := range list.Updates {
if u.Kind != "" && u.Kind != "package" {
continue
}
pkgs = append(pkgs, Package{
Name: u.Name,
Repo: RepoSystem,
Backend: "zypper",
FromVersion: u.EditionOld,
ToVersion: u.Edition,
})
}
return pkgs, nil
}
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
}
names := pickTargetNames(opts.Targets, "zypper", true)
if len(names) == 0 {
return nil
}
argv := append([]string{"pkexec", "zypper", "--non-interactive", "update"}, names...)
return Run(ctx, argv, RunOptions{OnLine: onLine})
}

View File

@@ -1,80 +0,0 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseZypperXML(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "empty stream",
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
want: []Package{},
},
{
name: "single package update",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
<source url="https://download.opensuse.org/" alias="repo-oss"/>
</update>
</update-list>
</stream>`,
want: []Package{
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "skips non-package kinds",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
</update-list>
</stream>`,
want: []Package{
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
},
},
{
name: "treats missing kind as package",
input: `<?xml version="1.0"?>
<stream><update-list>
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
</update-list></stream>`,
want: []Package{
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
},
},
{
name: "malformed XML returns error",
input: `not xml at all`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseZypperXML([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -1,125 +0,0 @@
package sysupdate
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"sync"
"syscall"
)
type RunOptions struct {
Env []string
OnLine func(string)
}
func Run(ctx context.Context, argv []string, opts RunOptions) error {
if len(argv) == 0 {
return fmt.Errorf("sysupdate.Run: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
if len(opts.Env) > 0 {
cmd.Env = append(cmd.Environ(), opts.Env...)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
var wg sync.WaitGroup
wg.Add(2)
go pump(stdout, opts.OnLine, &wg)
go pump(stderr, opts.OnLine, &wg)
wg.Wait()
return cmd.Wait()
}
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
defer wg.Done()
if onLine == nil {
_, _ = io.Copy(io.Discard, r)
return
}
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
onLine(scanner.Text())
}
}
func Capture(ctx context.Context, argv []string) (string, error) {
if len(argv) == 0 {
return "", fmt.Errorf("sysupdate.Capture: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
return string(out), err
}
func findTerminal(override string) string {
if override != "" && commandExists(override) {
return override
}
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
return t
}
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
if commandExists(t) {
return t
}
}
return ""
}
func wrapInTerminal(term, title, shellCmd string) []string {
const appID = "dms-sysupdate"
banner := fmt.Sprintf(
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
title, shellCmd,
)
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
switch term {
case "kitty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "foot":
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "ghostty":
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "xterm":
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
case "konsole":
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
case "gnome-terminal":
return []string{term, "--title=" + title, "--", "sh", "-c", full}
default:
return []string{term, "-e", "sh", "-c", full}
}
}

View File

@@ -1,55 +0,0 @@
package sysupdate
import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
switch req.Method {
case "sysupdate.getState":
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.refresh":
force := params.BoolOpt(req.Params, "force", false)
m.Refresh(RefreshOptions{Force: force})
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.upgrade":
handleUpgrade(conn, req, m)
case "sysupdate.cancel":
m.Cancel()
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.acquire":
m.Acquire()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.release":
m.Release()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.setInterval":
seconds, err := params.Int(req.Params, "seconds")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
m.SetInterval(seconds)
models.Respond(conn, req.ID, m.GetState())
default:
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
}
}
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
opts := UpgradeOptions{
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""),
}
if err := m.Upgrade(opts); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, m.GetState())
}

View File

@@ -1,506 +0,0 @@
package sysupdate
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
const (
defaultIntervalSeconds = 30 * 60
minIntervalSeconds = 5 * 60
recentLogCapacity = 200
checkTimeout = 5 * time.Minute
upgradeTimeout = 30 * time.Minute
)
type Manager struct {
mu sync.RWMutex
state State
subscribers syncmap.Map[string, chan State]
selection Selection
notifyDirty chan struct{}
stopChan chan struct{}
notifierWG sync.WaitGroup
schedulerWG sync.WaitGroup
acquireCount int32
wakeSched chan struct{}
refreshSerial sync.Mutex
opMu sync.Mutex
opCtx context.Context
opCancel context.CancelFunc
}
func NewManager() (*Manager, error) {
m := &Manager{
notifyDirty: make(chan struct{}, 1),
stopChan: make(chan struct{}),
wakeSched: make(chan struct{}, 1),
}
m.state = State{
Phase: PhaseIdle,
IntervalSeconds: defaultIntervalSeconds,
Backends: []BackendInfo{},
Packages: []Package{},
}
id, pretty := readOSRelease()
m.state.Distro = id
m.state.DistroPretty = pretty
m.selection = Select(context.Background())
m.state.Backends = m.selection.Info()
if len(m.state.Backends) == 0 {
m.state.Error = &ErrorInfo{
Code: ErrCodeNoBackend,
Message: "no supported package manager found",
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
}
}
m.notifierWG.Add(1)
go m.notifier()
m.schedulerWG.Add(1)
go m.scheduler()
go m.runRefresh(context.Background())
return m, nil
}
func (m *Manager) GetState() State {
m.mu.RLock()
defer m.mu.RUnlock()
return cloneState(m.state)
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 16)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) Close() {
select {
case <-m.stopChan:
return
default:
close(m.stopChan)
}
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel()
}
m.opMu.Unlock()
select {
case m.wakeSched <- struct{}{}:
default:
}
m.schedulerWG.Wait()
m.notifierWG.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
func (m *Manager) SetInterval(seconds int) {
if seconds < minIntervalSeconds {
seconds = minIntervalSeconds
}
m.mu.Lock()
m.state.IntervalSeconds = seconds
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) Refresh(opts RefreshOptions) {
m.mu.RLock()
phase := m.state.Phase
m.mu.RUnlock()
switch {
case phase == PhaseUpgrading:
return
case phase == PhaseRefreshing && !opts.Force:
m.refreshSerial.Lock()
m.refreshSerial.Unlock()
return
}
m.runRefresh(context.Background())
}
func (m *Manager) Upgrade(opts UpgradeOptions) error {
if len(m.selection.All()) == 0 {
return errors.New("no backend available")
}
m.opMu.Lock()
if m.opCancel != nil {
m.opMu.Unlock()
return errors.New("operation already running")
}
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
m.opCtx = ctx
m.opCancel = cancel
m.opMu.Unlock()
go m.runUpgrade(ctx, opts)
return nil
}
func (m *Manager) Cancel() {
m.opMu.Lock()
cancel := m.opCancel
m.opMu.Unlock()
if cancel == nil {
return
}
cancel()
}
func (m *Manager) Acquire() {
first := atomic.AddInt32(&m.acquireCount, 1) == 1
select {
case m.wakeSched <- struct{}{}:
default:
}
if first {
go m.runRefresh(context.Background())
}
}
func (m *Manager) Release() {
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
atomic.StoreInt32(&m.acquireCount, 0)
}
}
func (m *Manager) scheduler() {
defer m.schedulerWG.Done()
for {
if atomic.LoadInt32(&m.acquireCount) == 0 {
select {
case <-m.stopChan:
return
case <-m.wakeSched:
}
continue
}
m.mu.RLock()
interval := m.state.IntervalSeconds
m.mu.RUnlock()
if interval < minIntervalSeconds {
interval = minIntervalSeconds
}
t := time.NewTimer(time.Duration(interval) * time.Second)
select {
case <-m.stopChan:
t.Stop()
return
case <-m.wakeSched:
t.Stop()
case <-t.C:
m.runRefresh(context.Background())
}
}
}
func (m *Manager) runRefresh(parent context.Context) {
m.refreshSerial.Lock()
defer m.refreshSerial.Unlock()
if len(m.selection.All()) == 0 {
return
}
ctx, cancel := context.WithTimeout(parent, checkTimeout)
defer cancel()
m.mu.Lock()
if m.state.Phase == PhaseUpgrading {
m.mu.Unlock()
return
}
m.state.Phase = PhaseRefreshing
m.state.Error = nil
m.state.RecentLog = nil
m.mu.Unlock()
m.markDirty()
type backendResult struct {
pkgs []Package
err error
}
backends := m.selection.All()
results := make([]backendResult, len(backends))
var wg sync.WaitGroup
for i, b := range backends {
wg.Add(1)
go func(i int, b Backend) {
defer wg.Done()
pkgs, err := b.CheckUpdates(ctx)
results[i] = backendResult{pkgs: pkgs, err: err}
}(i, b)
}
wg.Wait()
now := time.Now().Unix()
m.mu.Lock()
m.state.LastCheckUnix = now
m.state.Packages = m.state.Packages[:0]
var firstErr error
for i, r := range results {
if r.err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
}
continue
}
m.state.Packages = append(m.state.Packages, r.pkgs...)
}
m.state.Count = len(m.state.Packages)
if firstErr != nil {
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
} else {
m.state.Phase = PhaseIdle
m.state.LastSuccessUnix = now
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
defer func() {
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel = nil
m.opCtx = nil
}
m.opMu.Unlock()
}()
if opts.CustomCommand != "" {
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
return
}
backends := upgradeBackends(m.selection, opts)
if len(backends) == 0 {
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
return
}
if len(opts.Targets) == 0 {
m.mu.RLock()
opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock()
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
for _, b := range backends {
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
if err := b.Upgrade(ctx, opts, onLine); err != nil {
code := ErrCodeBackendFailed
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = ErrCodeTimeout
} else if errors.Is(ctx.Err(), context.Canceled) {
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
m.mu.Unlock()
m.markDirty()
return
}
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
term := findTerminal(terminalOverride)
if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
code = ErrCodeTimeout
case errors.Is(ctx.Err(), context.Canceled):
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
var out []Backend
if sel.System != nil {
out = append(out, sel.System)
}
for _, b := range sel.Overlay {
switch {
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
continue
}
out = append(out, b)
}
return out
}
func (m *Manager) appendLog(line string) {
m.mu.Lock()
if cap(m.state.RecentLog) == 0 {
m.state.RecentLog = make([]string, 0, recentLogCapacity)
}
if len(m.state.RecentLog) >= recentLogCapacity {
copy(m.state.RecentLog, m.state.RecentLog[1:])
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
}
m.state.RecentLog = append(m.state.RecentLog, line)
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) setError(code ErrorCode, msg string) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: msg}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) markDirty() {
select {
case m.notifyDirty <- struct{}{}:
default:
}
}
func (m *Manager) notifier() {
defer m.notifierWG.Done()
for {
select {
case <-m.stopChan:
return
case <-m.notifyDirty:
snap := m.GetState()
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- snap:
default:
}
return true
})
}
}
}
func cloneState(s State) State {
out := s
out.Backends = append([]BackendInfo(nil), s.Backends...)
out.Packages = append([]Package(nil), s.Packages...)
out.RecentLog = append([]string(nil), s.RecentLog...)
if s.Error != nil {
errCopy := *s.Error
out.Error = &errCopy
}
return out
}
func readOSRelease() (id, pretty string) {
f, err := os.Open("/etc/os-release")
if err != nil {
return "", ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
k, v, ok := strings.Cut(scanner.Text(), "=")
if !ok {
continue
}
v = strings.Trim(v, "\"")
switch k {
case "ID":
id = v
case "PRETTY_NAME":
pretty = v
}
}
if err := scanner.Err(); err != nil {
log.Debugf("[sysupdate] read os-release: %v", err)
}
return id, pretty
}

View File

@@ -1,86 +0,0 @@
package sysupdate
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseRefreshing Phase = "refreshing"
PhaseUpgrading Phase = "upgrading"
PhaseError Phase = "error"
)
type RepoKind string
const (
RepoSystem RepoKind = "system"
RepoAUR RepoKind = "aur"
RepoFlatpak RepoKind = "flatpak"
RepoOSTree RepoKind = "ostree"
)
type ErrorCode string
const (
ErrCodeNone ErrorCode = ""
ErrCodeNoBackend ErrorCode = "no-backend"
ErrCodeBusy ErrorCode = "busy"
ErrCodeBackendFailed ErrorCode = "backend-failed"
ErrCodeTimeout ErrorCode = "timeout"
ErrCodeCancelled ErrorCode = "cancelled"
ErrCodeInvalidRequest ErrorCode = "invalid-request"
)
type Package struct {
Name string `json:"name"`
Repo RepoKind `json:"repo"`
Backend string `json:"backend"`
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
ChangelogURL string `json:"changelogUrl,omitempty"`
Ref string `json:"-"`
}
type BackendInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Repo RepoKind `json:"repo"`
NeedsAuth bool `json:"needsAuth"`
RunsInTerminal bool `json:"runsInTerminal"`
}
type ErrorInfo struct {
Code ErrorCode `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Hint string `json:"hint,omitempty"`
}
type State struct {
Phase Phase `json:"phase"`
Distro string `json:"distro,omitempty"`
DistroPretty string `json:"distroPretty,omitempty"`
Backends []BackendInfo `json:"backends"`
Packages []Package `json:"packages"`
Count int `json:"count"`
IntervalSeconds int `json:"intervalSeconds"`
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
OperationID string `json:"operationId,omitempty"`
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
RecentLog []string `json:"recentLog,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
type UpgradeOptions struct {
IncludeFlatpak bool
IncludeAUR bool
DryRun bool
CustomCommand string
Terminal string
Targets []Package
}
type RefreshOptions struct {
Force bool
}

View File

@@ -1,115 +0,0 @@
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)
}

View File

@@ -1,262 +0,0 @@
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
}

View File

@@ -3,11 +3,8 @@ package wayland
import ( import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"io"
"os" "os"
"slices"
"syscall" "syscall"
"time" "time"
@@ -76,10 +73,7 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
m.post(func() { m.post(func() {
log.Info("Gamma control enabled at startup") log.Info("Gamma control enabled at startup")
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
m.availOutputsMu.RLock() if err := m.setupOutputControls(m.availableOutputs, gammaMgr); err != nil {
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) log.Errorf("Failed to initialize gamma controls: %v", err)
return return
} }
@@ -176,7 +170,6 @@ func (m *Manager) setupRegistry() error {
}) })
if gammaMgr != nil { if gammaMgr != nil {
outputs = append(outputs, output) outputs = append(outputs, output)
m.addAvailableOutput(output)
} }
m.outputRegNames.Store(outputID, e.Name) m.outputRegNames.Store(outputID, e.Name)
@@ -211,11 +204,6 @@ func (m *Manager) setupRegistry() error {
} }
if foundOut.gammaControl != nil { if foundOut.gammaControl != nil {
foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy() 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) m.outputs.Delete(foundID)
@@ -300,28 +288,14 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
if !ok { if !ok {
return return
} }
if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() {
ctrl.Destroy()
}
out.gammaControl = nil
out.failed = true out.failed = true
out.rampSize = 0 out.rampSize = 0
out.retryCount++ out.retryCount++
out.lastFailTime = time.Now() out.lastFailTime = time.Now()
if !m.outputStillValid(out) {
return
}
backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
time.AfterFunc(backoff, func() { time.AfterFunc(backoff, func() {
m.post(func() { m.post(func() {
if !m.outputStillValid(out) {
return
}
if _, stillTracked := m.outputs.Load(outputID); !stillTracked {
return
}
m.recreateOutputControl(out) m.recreateOutputControl(out)
}) })
}) })
@@ -329,75 +303,12 @@ 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 { 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() outputID := output.ID()
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
control, err := gammaMgr.GetGammaControl(output) control, err := gammaMgr.GetGammaControl(output)
if err != nil { if err != nil {
if isConnectionDeadErr(err) {
m.markConnectionDead(err)
}
return err return err
} }
@@ -418,37 +329,26 @@ func (m *Manager) recreateOutputControl(out *outputState) error {
enabled := m.config.Enabled enabled := m.config.Enabled
m.configMutex.RUnlock() m.configMutex.RUnlock()
switch { if !enabled || !m.controlsInitialized {
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 return nil
} }
if _, ok := m.outputs.Load(out.id); !ok { if _, ok := m.outputs.Load(out.id); !ok {
return nil return nil
} }
if out.isVirtual {
return nil
}
if out.retryCount >= 10 {
return nil
}
gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
if !ok { if !ok {
return fmt.Errorf("no gamma manager") 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) control, err := gammaMgr.GetGammaControl(out.output)
if err != nil { if err != nil {
if isConnectionDeadErr(err) {
m.markConnectionDead(err)
}
return err return err
} }
@@ -458,13 +358,6 @@ func (m *Manager) recreateOutputControl(out *outputState) error {
return nil 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) { func (m *Manager) recalcSchedule(now time.Time) {
m.configMutex.RLock() m.configMutex.RLock()
config := m.config config := m.config
@@ -797,12 +690,11 @@ func (m *Manager) applyGamma(temp int) {
gamma := m.config.Gamma gamma := m.config.Gamma
m.configMutex.RUnlock() m.configMutex.RUnlock()
switch { if !m.controlsInitialized {
case m.connectionDead.Load():
return return
case !m.controlsInitialized: }
return
case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma: if m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma {
return return
} }
@@ -822,14 +714,7 @@ func (m *Manager) applyGamma(temp int) {
var jobs []job var jobs []job
for _, out := range outs { for _, out := range outs {
switch { if out.failed || out.rampSize == 0 {
case out.failed:
continue
case out.rampSize == 0:
continue
case out.gammaControl == nil:
continue
case !m.outputStillValid(out):
continue continue
} }
ramp := GenerateGammaRamp(out.rampSize, temp, gamma) ramp := GenerateGammaRamp(out.rampSize, temp, gamma)
@@ -847,16 +732,18 @@ func (m *Manager) applyGamma(temp int) {
} }
for _, j := range jobs { for _, j := range jobs {
err := m.setGammaBytes(j.out, j.data) if err := m.setGammaBytes(j.out, j.data); err != nil {
if err == nil { log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
continue j.out.failed = true
} j.out.rampSize = 0
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err) outID := j.out.id
j.out.failed = true time.AfterFunc(300*time.Millisecond, func() {
j.out.rampSize = 0 m.post(func() {
if isConnectionDeadErr(err) { if out, ok := m.outputs.Load(outID); ok && out.failed {
m.markConnectionDead(err) m.recreateOutputControl(out)
return }
})
})
} }
} }
@@ -865,14 +752,6 @@ func (m *Manager) applyGamma(temp int) {
} }
func (m *Manager) setGammaBytes(out *outputState, data []byte) error { 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) fd, err := MemfdCreate("gamma-ramp", 0)
if err != nil { if err != nil {
return err return err
@@ -895,6 +774,7 @@ func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
} }
syscall.Seek(fd, 0, 0) syscall.Seek(fd, 0, 0)
ctrl := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
return ctrl.SetGamma(fd) return ctrl.SetGamma(fd)
} }
@@ -1002,10 +882,10 @@ func (m *Manager) dbusMonitor() {
} }
func (m *Manager) handleDBusSignal(sig *dbus.Signal) { func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
switch { if sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep" {
case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep":
return return
case len(sig.Body) == 0: }
if len(sig.Body) == 0 {
return return
} }
preparing, ok := sig.Body[0].(bool) preparing, ok := sig.Body[0].(bool)
@@ -1019,34 +899,27 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
return return
} }
time.AfterFunc(500*time.Millisecond, func() { time.AfterFunc(500*time.Millisecond, func() {
m.post(m.handleResume) 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
})
})
}) })
} }
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() { func (m *Manager) triggerUpdate() {
select { select {
case m.updateTrigger <- struct{}{}: case m.updateTrigger <- struct{}{}:
@@ -1185,10 +1058,7 @@ func (m *Manager) SetEnabled(enabled bool) {
case enabled && !m.controlsInitialized: case enabled && !m.controlsInitialized:
m.post(func() { m.post(func() {
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
m.availOutputsMu.RLock() if err := m.setupOutputControls(m.availableOutputs, gammaMgr); err != nil {
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) log.Errorf("gamma: failed to create controls: %v", err)
return return
} }

View File

@@ -3,7 +3,6 @@ package wayland
import ( import (
"math" "math"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
@@ -72,11 +71,9 @@ type Manager struct {
registry *wlclient.Registry registry *wlclient.Registry
gammaControl any gammaControl any
availableOutputs []*wlclient.Output availableOutputs []*wlclient.Output
availOutputsMu sync.RWMutex
outputRegNames syncmap.Map[uint32, uint32] outputRegNames syncmap.Map[uint32, uint32]
outputs syncmap.Map[uint32, *outputState] outputs syncmap.Map[uint32, *outputState]
controlsInitialized bool controlsInitialized bool
connectionDead atomic.Bool
cmdq chan cmd cmdq chan cmd
alive bool alive bool

View File

@@ -1,455 +0,0 @@
// Package trash implements the FreeDesktop.org Trash specification 1.0.
// See: https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
package trash
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
const trashInfoExt = ".trashinfo"
type Entry struct {
Name string `json:"name"`
OriginalPath string `json:"originalPath"`
DeletionDate string `json:"deletionDate"`
TrashDir string `json:"trashDir"`
FilesPath string `json:"filesPath"`
InfoPath string `json:"infoPath"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
}
func homeTrashDir() (string, error) {
xdg := os.Getenv("XDG_DATA_HOME")
if xdg == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
xdg = filepath.Join(home, ".local", "share")
}
return filepath.Join(xdg, "Trash"), nil
}
func ensureTrashDirs(trashDir string) error {
if err := os.MkdirAll(filepath.Join(trashDir, "files"), 0o700); err != nil {
return err
}
return os.MkdirAll(filepath.Join(trashDir, "info"), 0o700)
}
func fsDevice(path string) (uint64, error) {
var st syscall.Stat_t
if err := syscall.Lstat(path, &st); err != nil {
return 0, err
}
return uint64(st.Dev), nil
}
func fsDeviceWalkUp(start string) (uint64, error) {
cur := start
for {
if dev, err := fsDevice(cur); err == nil {
return dev, nil
}
parent := filepath.Dir(cur)
if parent == cur {
return 0, fmt.Errorf("no existing ancestor for %s", start)
}
cur = parent
}
}
func findTopDir(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
dev, err := fsDevice(abs)
if err != nil {
return "", err
}
cur := abs
for {
parent := filepath.Dir(cur)
if parent == cur {
return cur, nil
}
pdev, err := fsDevice(parent)
if err != nil {
return cur, nil
}
if pdev != dev {
return cur, nil
}
cur = parent
}
}
// isValidSharedTrash enforces the spec's checks on $topdir/.Trash:
// must exist, must be a directory, must not be a symlink, must have sticky bit.
func isValidSharedTrash(p string) bool {
info, err := os.Lstat(p)
if err != nil {
return false
}
if info.Mode()&os.ModeSymlink != 0 {
return false
}
if !info.IsDir() {
return false
}
return info.Mode()&os.ModeSticky != 0
}
// trashDirForPath chooses the correct trash dir per spec and returns the value
// to store in the .trashinfo Path field (absolute for home, relative-to-topdir
// for per-mountpoint trash).
func trashDirForPath(absPath string) (trashDir string, storedPath string, err error) {
home, err := homeTrashDir()
if err != nil {
return "", "", err
}
pathDev, err := fsDevice(absPath)
if err != nil {
return "", "", err
}
homeDev, err := fsDeviceWalkUp(home)
if err != nil {
return "", "", err
}
if pathDev == homeDev {
return home, absPath, nil
}
topDir, err := findTopDir(absPath)
if err != nil {
return "", "", err
}
uid := strconv.Itoa(os.Getuid())
stored, rerr := filepath.Rel(topDir, absPath)
if rerr != nil || strings.HasPrefix(stored, "..") {
stored = absPath
}
shared := filepath.Join(topDir, ".Trash")
if isValidSharedTrash(shared) {
return filepath.Join(shared, uid), stored, nil
}
return filepath.Join(topDir, ".Trash-"+uid), stored, nil
}
// uniqueName returns a basename in trashDir that does not collide with an
// existing entry in either files/ or info/.
func uniqueName(trashDir, basename string) (string, error) {
filesDir := filepath.Join(trashDir, "files")
infoDir := filepath.Join(trashDir, "info")
if !exists(filepath.Join(filesDir, basename)) && !exists(filepath.Join(infoDir, basename+trashInfoExt)) {
return basename, nil
}
ext := filepath.Ext(basename)
stem := strings.TrimSuffix(basename, ext)
for i := 2; i < 100000; i++ {
candidate := fmt.Sprintf("%s.%d%s", stem, i, ext)
if !exists(filepath.Join(filesDir, candidate)) && !exists(filepath.Join(infoDir, candidate+trashInfoExt)) {
return candidate, nil
}
}
return "", errors.New("could not find unique trash name")
}
func exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
// pathEncode percent-escapes a POSIX path per RFC 2396, preserving "/".
func pathEncode(p string) string {
parts := strings.Split(p, "/")
for i, seg := range parts {
parts[i] = url.PathEscape(seg)
}
return strings.Join(parts, "/")
}
func pathDecode(p string) string {
if d, err := url.PathUnescape(p); err == nil {
return d
}
return p
}
func writeTrashInfo(infoPath, storedPath string, when time.Time) error {
body := "[Trash Info]\nPath=" + pathEncode(storedPath) +
"\nDeletionDate=" + when.Format("2006-01-02T15:04:05") + "\n"
f, err := os.OpenFile(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(body)
return err
}
// Put trashes a single file or directory.
func Put(path string) (Entry, error) {
abs, err := filepath.Abs(path)
if err != nil {
return Entry{}, err
}
info, err := os.Lstat(abs)
if err != nil {
return Entry{}, err
}
trashDir, storedPath, err := trashDirForPath(abs)
if err != nil {
return Entry{}, err
}
if err := ensureTrashDirs(trashDir); err != nil {
return Entry{}, fmt.Errorf("create trash dir %s: %w", trashDir, err)
}
name, err := uniqueName(trashDir, filepath.Base(abs))
if err != nil {
return Entry{}, err
}
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
when := time.Now()
if err := writeTrashInfo(infoPath, storedPath, when); err != nil {
return Entry{}, err
}
target := filepath.Join(trashDir, "files", name)
if err := os.Rename(abs, target); err != nil {
os.Remove(infoPath)
return Entry{}, err
}
return Entry{
Name: name,
OriginalPath: storedPath,
DeletionDate: when.Format("2006-01-02T15:04:05"),
TrashDir: trashDir,
FilesPath: target,
InfoPath: infoPath,
Size: info.Size(),
IsDir: info.IsDir(),
}, nil
}
// allTrashDirs returns the home trash plus every per-mountpoint trash dir
// that exists (and passes the spec's safety checks for $topdir/.Trash).
func allTrashDirs() []string {
var dirs []string
if h, err := homeTrashDir(); err == nil {
dirs = append(dirs, h)
}
uid := strconv.Itoa(os.Getuid())
for _, mount := range readMountPoints() {
shared := filepath.Join(mount, ".Trash")
if isValidSharedTrash(shared) {
candidate := filepath.Join(shared, uid)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
dirs = append(dirs, candidate)
}
}
candidate := filepath.Join(mount, ".Trash-"+uid)
if info, err := os.Lstat(candidate); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
dirs = append(dirs, candidate)
}
}
return dirs
}
// readMountPoints returns user-visible mount points from /proc/self/mountinfo,
// skipping pseudo and system filesystems.
func readMountPoints() []string {
data, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return nil
}
skipPrefixes := []string{"/proc", "/sys", "/dev"}
var out []string
seen := map[string]bool{}
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
mp := fields[4]
if mp == "/" {
continue
}
skip := false
for _, p := range skipPrefixes {
if mp == p || strings.HasPrefix(mp, p+"/") {
skip = true
break
}
}
if skip || seen[mp] {
continue
}
seen[mp] = true
out = append(out, mp)
}
return out
}
func List() ([]Entry, error) {
var entries []Entry
for _, d := range allTrashDirs() {
es, _ := listOne(d)
entries = append(entries, es...)
}
return entries, nil
}
func listOne(trashDir string) ([]Entry, error) {
infoDir := filepath.Join(trashDir, "info")
filesDir := filepath.Join(trashDir, "files")
dir, err := os.ReadDir(infoDir)
if err != nil {
return nil, err
}
var entries []Entry
for _, ent := range dir {
if !strings.HasSuffix(ent.Name(), trashInfoExt) {
continue
}
name := strings.TrimSuffix(ent.Name(), trashInfoExt)
infoPath := filepath.Join(infoDir, ent.Name())
filesPath := filepath.Join(filesDir, name)
body, err := os.ReadFile(infoPath)
if err != nil {
continue
}
e := Entry{Name: name, TrashDir: trashDir, InfoPath: infoPath, FilesPath: filesPath}
for line := range strings.SplitSeq(string(body), "\n") {
if v, ok := strings.CutPrefix(line, "Path="); ok {
e.OriginalPath = pathDecode(v)
continue
}
if v, ok := strings.CutPrefix(line, "DeletionDate="); ok {
e.DeletionDate = v
}
}
if info, err := os.Lstat(filesPath); err == nil {
e.Size = info.Size()
e.IsDir = info.IsDir()
}
entries = append(entries, e)
}
return entries, nil
}
func Count() (int, error) {
n := 0
for _, d := range allTrashDirs() {
ents, err := os.ReadDir(filepath.Join(d, "info"))
if err != nil {
continue
}
for _, e := range ents {
if strings.HasSuffix(e.Name(), trashInfoExt) {
n++
}
}
}
return n, nil
}
func Empty() error {
var firstErr error
for _, d := range allTrashDirs() {
if err := emptyOne(d); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func emptyOne(trashDir string) error {
var firstErr error
for _, sub := range []string{"files", "info"} {
path := filepath.Join(trashDir, sub)
ents, err := os.ReadDir(path)
if err != nil {
continue
}
for _, e := range ents {
if err := os.RemoveAll(filepath.Join(path, e.Name())); err != nil && firstErr == nil {
firstErr = err
}
}
}
os.Remove(filepath.Join(trashDir, "directorysizes"))
return firstErr
}
// Restore returns a trashed item to its original location.
func Restore(name, trashDir string) error {
if trashDir == "" {
h, err := homeTrashDir()
if err != nil {
return err
}
trashDir = h
}
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
filesPath := filepath.Join(trashDir, "files", name)
body, err := os.ReadFile(infoPath)
if err != nil {
return err
}
var stored string
for line := range strings.SplitSeq(string(body), "\n") {
if v, ok := strings.CutPrefix(line, "Path="); ok {
stored = pathDecode(v)
break
}
}
if stored == "" {
return errors.New("invalid .trashinfo: missing Path")
}
target := stored
if !filepath.IsAbs(stored) {
topDir := filepath.Dir(trashDir)
if filepath.Base(topDir) == ".Trash" {
topDir = filepath.Dir(topDir)
}
target = filepath.Join(topDir, stored)
}
if exists(target) {
return fmt.Errorf("restore target already exists: %s", target)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := os.Rename(filesPath, target); err != nil {
return err
}
os.Remove(infoPath)
return nil
}

View File

@@ -1,315 +0,0 @@
package trash
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func setupHomeTrash(t *testing.T) (homeRoot string, trashDir string) {
t.Helper()
homeRoot = t.TempDir()
xdg := filepath.Join(homeRoot, ".local", "share")
if err := os.MkdirAll(xdg, 0o700); err != nil {
t.Fatalf("mkdir xdg: %v", err)
}
t.Setenv("XDG_DATA_HOME", xdg)
t.Setenv("HOME", homeRoot)
trashDir = filepath.Join(xdg, "Trash")
return homeRoot, trashDir
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestPutHomeTrashAbsolutePath(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
src := filepath.Join(homeRoot, "doc.txt")
writeFile(t, src, "hi")
entry, err := Put(src)
if err != nil {
t.Fatalf("Put: %v", err)
}
if entry.Name != "doc.txt" {
t.Errorf("name = %q, want doc.txt", entry.Name)
}
if entry.OriginalPath != src {
t.Errorf("originalPath = %q, want %q", entry.OriginalPath, src)
}
if entry.TrashDir != trashDir {
t.Errorf("trashDir = %q, want %q", entry.TrashDir, trashDir)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Errorf("source still exists: %v", err)
}
body, err := os.ReadFile(filepath.Join(trashDir, "info", "doc.txt.trashinfo"))
if err != nil {
t.Fatalf("read trashinfo: %v", err)
}
if !strings.HasPrefix(string(body), "[Trash Info]\n") {
t.Errorf("trashinfo missing header: %q", body)
}
if !strings.Contains(string(body), "Path="+src+"\n") {
t.Errorf("Path key missing or wrong: %q", body)
}
if !strings.Contains(string(body), "DeletionDate=") {
t.Errorf("DeletionDate missing: %q", body)
}
}
func TestPutPercentEncodesPath(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
name := "spaces & %.txt"
src := filepath.Join(homeRoot, name)
writeFile(t, src, "x")
if _, err := Put(src); err != nil {
t.Fatalf("Put: %v", err)
}
body, err := os.ReadFile(filepath.Join(trashDir, "info", name+".trashinfo"))
if err != nil {
t.Fatalf("read: %v", err)
}
want := "Path=" + filepath.Dir(src) + "/spaces%20&%20%25.txt"
if !strings.Contains(string(body), want) {
t.Errorf("expected %q in %q", want, body)
}
}
func TestPutCollisionGetsUniqueName(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
for i := range 3 {
src := filepath.Join(homeRoot, "dup.txt")
writeFile(t, src, "x")
if _, err := Put(src); err != nil {
t.Fatalf("Put #%d: %v", i, err)
}
}
want := []string{"dup.txt", "dup.2.txt", "dup.3.txt"}
for _, n := range want {
if _, err := os.Stat(filepath.Join(trashDir, "files", n)); err != nil {
t.Errorf("expected %s in trash: %v", n, err)
}
if _, err := os.Stat(filepath.Join(trashDir, "info", n+".trashinfo")); err != nil {
t.Errorf("expected %s.trashinfo: %v", n, err)
}
}
}
func TestListAndCount(t *testing.T) {
homeRoot, _ := setupHomeTrash(t)
if n, _ := Count(); n != 0 {
t.Errorf("initial count = %d, want 0", n)
}
entries, _ := List()
if len(entries) != 0 {
t.Errorf("initial list len = %d, want 0", len(entries))
}
for _, n := range []string{"a.txt", "b.txt", "c.log"} {
src := filepath.Join(homeRoot, n)
writeFile(t, src, n)
if _, err := Put(src); err != nil {
t.Fatalf("Put %s: %v", n, err)
}
}
got, _ := Count()
if got != 3 {
t.Errorf("count = %d, want 3", got)
}
entries, _ = List()
if len(entries) != 3 {
t.Errorf("list len = %d, want 3", len(entries))
}
for _, e := range entries {
if e.OriginalPath == "" {
t.Errorf("entry %s: empty OriginalPath", e.Name)
}
if _, err := time.Parse("2006-01-02T15:04:05", e.DeletionDate); err != nil {
t.Errorf("entry %s: bad DeletionDate %q: %v", e.Name, e.DeletionDate, err)
}
}
}
func TestEmptyClearsAll(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
for _, n := range []string{"x", "y", "z"} {
src := filepath.Join(homeRoot, n)
writeFile(t, src, n)
if _, err := Put(src); err != nil {
t.Fatalf("Put: %v", err)
}
}
if n, _ := Count(); n != 3 {
t.Fatalf("pre-empty count = %d", n)
}
if err := Empty(); err != nil {
t.Fatalf("Empty: %v", err)
}
if n, _ := Count(); n != 0 {
t.Errorf("post-empty count = %d, want 0", n)
}
for _, sub := range []string{"files", "info"} {
ents, err := os.ReadDir(filepath.Join(trashDir, sub))
if err != nil {
t.Fatalf("readdir %s: %v", sub, err)
}
if len(ents) != 0 {
t.Errorf("%s/ has %d entries, want 0", sub, len(ents))
}
}
}
func TestRestoreToOriginalPath(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
src := filepath.Join(homeRoot, "sub", "dir", "thing.txt")
writeFile(t, src, "payload")
entry, err := Put(src)
if err != nil {
t.Fatalf("Put: %v", err)
}
os.RemoveAll(filepath.Join(homeRoot, "sub"))
if err := Restore(entry.Name, trashDir); err != nil {
t.Fatalf("Restore: %v", err)
}
body, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read restored: %v", err)
}
if string(body) != "payload" {
t.Errorf("restored content = %q, want %q", body, "payload")
}
if _, err := os.Stat(entry.InfoPath); !os.IsNotExist(err) {
t.Errorf("info file still present: %v", err)
}
if _, err := os.Stat(entry.FilesPath); !os.IsNotExist(err) {
t.Errorf("files entry still present: %v", err)
}
}
func TestRestoreRefusesToOverwrite(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
src := filepath.Join(homeRoot, "keep.txt")
writeFile(t, src, "v1")
entry, err := Put(src)
if err != nil {
t.Fatalf("Put: %v", err)
}
writeFile(t, src, "v2-blocking")
err = Restore(entry.Name, trashDir)
if err == nil {
t.Fatalf("expected error on conflicting restore, got nil")
}
if !strings.Contains(err.Error(), "exists") {
t.Errorf("error %q does not mention conflict", err)
}
body, _ := os.ReadFile(src)
if string(body) != "v2-blocking" {
t.Errorf("blocking file altered: %q", body)
}
}
func TestPutDirectory(t *testing.T) {
homeRoot, trashDir := setupHomeTrash(t)
dir := filepath.Join(homeRoot, "myfolder")
writeFile(t, filepath.Join(dir, "child.txt"), "inside")
entry, err := Put(dir)
if err != nil {
t.Fatalf("Put dir: %v", err)
}
if !entry.IsDir {
t.Errorf("IsDir = false, want true")
}
moved := filepath.Join(trashDir, "files", "myfolder", "child.txt")
body, err := os.ReadFile(moved)
if err != nil {
t.Fatalf("read moved child: %v", err)
}
if string(body) != "inside" {
t.Errorf("child content = %q", body)
}
}
func TestIsValidSharedTrashRejectsSymlink(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "real")
if err := os.MkdirAll(target, os.ModeSticky|0o777); err != nil {
t.Fatalf("mkdir target: %v", err)
}
link := filepath.Join(tmp, ".Trash")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if isValidSharedTrash(link) {
t.Errorf("symlinked .Trash accepted; spec requires rejection")
}
}
func TestIsValidSharedTrashRequiresStickyBit(t *testing.T) {
tmp := t.TempDir()
dir := filepath.Join(tmp, ".Trash")
if err := os.MkdirAll(dir, 0o777); err != nil {
t.Fatalf("mkdir: %v", err)
}
if isValidSharedTrash(dir) {
t.Errorf(".Trash without sticky bit accepted; spec requires rejection")
}
if err := os.Chmod(dir, os.ModeSticky|0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if !isValidSharedTrash(dir) {
t.Errorf(".Trash with sticky bit rejected; spec accepts it")
}
}
func TestPathEncodeRoundTrip(t *testing.T) {
cases := []string{
"/home/u/file.txt",
"/path with spaces/and-symbols & %.txt",
"relative/path/é unicode.md",
}
for _, in := range cases {
got := pathDecode(pathEncode(in))
if got != in {
t.Errorf("round-trip %q -> %q", in, got)
}
}
}

View File

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

View File

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

View File

@@ -180,7 +180,16 @@ func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
} }
return m.enterAuthPhase() // 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
}
case "esc": case "esc":
m.state = StateSelectWindowManager m.state = StateSelectWindowManager
return m, nil return m, nil

View File

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

View File

@@ -139,7 +139,7 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string {
if isGit { if isGit {
return "dms-shell-git" return "dms-shell-git"
} }
return "dms-shell" return "dms-shell-bin"
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE: case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE:
if isGit { if isGit {
return "dms-git" return "dms-git"

View File

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

View File

@@ -1,133 +0,0 @@
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
}

View File

@@ -2,10 +2,12 @@ package version
import ( import (
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
mocks_version "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/version" mocks_version "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/version"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
func TestCompareVersions(t *testing.T) { func TestCompareVersions(t *testing.T) {
@@ -148,6 +150,76 @@ func TestGetCurrentDMSVersion_NotInstalled(t *testing.T) {
} }
} }
func TestGetCurrentDMSVersion_GitTag(t *testing.T) {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0o644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
exec.Command("git", "-C", dmsPath, "tag", "v0.1.0").Run()
version, err := GetCurrentDMSVersion()
if err != nil {
t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
}
if version != "v0.1.0" {
t.Errorf("Expected version v0.1.0, got %s", version)
}
}
func TestGetCurrentDMSVersion_GitBranch(t *testing.T) {
if !utils.CommandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
exec.Command("git", "init", dmsPath).Run()
exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run()
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0o644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
version, err := GetCurrentDMSVersion()
if err != nil {
t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
}
if version == "" {
t.Error("Expected non-empty version")
}
if len(version) < 7 {
t.Errorf("Expected version with branch@commit format, got %s", version)
}
}
func TestVersionInfo_IsGit(t *testing.T) { func TestVersionInfo_IsGit(t *testing.T) {
tests := []struct { tests := []struct {
current string current string

View File

@@ -1,11 +0,0 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ../../flake.lock);
in
fetchTarball {
url =
lock.nodes.flake-compat.locked.url
or "https://github.com/NixOS/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
) { src = ../..; }).defaultNix

View File

@@ -139,13 +139,6 @@ in
''; '';
} }
]; ];
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here.
# security.pam.services.dankshell = {
# # Example: add faillock
# faillock.enable = true;
# };
services.greetd = { services.greetd = {
enable = lib.mkDefault true; enable = lib.mkDefault true;
settings.default_session.command = lib.mkDefault (lib.getExe greeterScript); settings.default_session.command = lib.mkDefault (lib.getExe greeterScript);

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