mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-02 10:32:07 -04:00
Compare commits
1 Commits
master
...
cbfb9f6dd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbfb9f6dd0 |
24
.github/workflows/nix-pr-check.yml
vendored
24
.github/workflows/nix-pr-check.yml
vendored
@@ -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
|
|
||||||
|
|||||||
2
.github/workflows/run-ppa.yml
vendored
2
.github/workflows/run-ppa.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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/)
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
|||||||
[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
|
[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
|
||||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
||||||
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
[](https://aur.archlinux.org/packages/dms-shell-bin)
|
||||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
||||||
[](https://ko-fi.com/danklinux)
|
[](https://ko-fi.com/danklinux)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
|||||||
Command-line interface and daemon for shell management and system control.
|
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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
42
core/go.mod
42
core/go.mod
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
102
core/go.sum
102
core/go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)$
|
||||||
|
|||||||
@@ -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$"#
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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...) }
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const (
|
|||||||
StateDependencyReview
|
StateDependencyReview
|
||||||
StateGentooUseFlags
|
StateGentooUseFlags
|
||||||
StateGentooGCCCheck
|
StateGentooGCCCheck
|
||||||
StateSelectPrivesc
|
|
||||||
StateAuthMethodChoice
|
StateAuthMethodChoice
|
||||||
StateFingerprintAuth
|
StateFingerprintAuth
|
||||||
StatePasswordPrompt
|
StatePasswordPrompt
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
Reference in New Issue
Block a user