1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00

switch hto monorepo structure

This commit is contained in:
bbedward
2025-11-12 17:18:45 -05:00
parent 6013c994a6
commit 24e800501a
768 changed files with 76284 additions and 221 deletions

View File

@@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
# DISABLED for now
exit 0
set -euo pipefail set -euo pipefail
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

37
.gitignore vendored
View File

@@ -102,3 +102,40 @@ go.work.sum
# Editor/IDE # Editor/IDE
# .idea/ # .idea/
# .vscode/ # .vscode/
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
bin/
dankinstall
/dms

220
README.md
View File

@@ -1,219 +1 @@
# DankMaterialShell (dms) # TODO
<div align="center">
<a href="https://danklinux.com">
<img src="assets/danklogo2.svg" alt="DankMaterialShell Logo" width="200">
</a>
### A modern Wayland desktop shell
Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![Documentation](https://img.shields.io/badge/docs-danklinux.com-9ccbfb?style=for-the-badge&labelColor=101418)](https://danklinux.com/docs)
[![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![AUR version](https://img.shields.io/aur/version/dms-shell-bin?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://aur.archlinux.org/packages/dms-shell-bin)
[![AUR version (git)](https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git))](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Favengemediallc)](https://ko-fi.com/avengemediallc)
</div>
DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hypr.land), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop - all in one cohesive package with a gorgeous interface.
## Components
DankMaterialShell combines two main components:
- **[QML/UI Layer](https://github.com/AvengeMedia/DankMaterialShell)** (this repo) - All the visual components, widgets, and shell interface built with Quickshell
- **[Go Backend](https://github.com/AvengeMedia/danklinux)** - System integration, IPC, process management, and core services
---
## See it in Action
<div align="center">
https://github.com/user-attachments/assets/1200a739-7770-4601-8b85-695ca527819a
</div>
<details><summary><strong>More Screenshots</strong></summary>
<div align="center">
<img src="https://github.com/user-attachments/assets/203a9678-c3b7-4720-bb97-853a511ac5c8" width="600" alt="Desktop" />
<img src="https://github.com/user-attachments/assets/a937cf35-a43b-4558-8c39-5694ff5fcac4" width="600" alt="Dashboard" />
<img src="https://github.com/user-attachments/assets/2da00ea1-8921-4473-a2a9-44a44535a822" width="450" alt="Launcher" />
<img src="https://github.com/user-attachments/assets/732c30de-5f4a-4a2b-a995-c8ab656cecd5" width="600" alt="Control Center" />
</div>
</details>
---
## Quick Install
```bash
curl -fsSL https://install.danklinux.com | sh
```
That's it. One command installs dms and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo.
**[Manual Installation Guide →](https://danklinux.com/docs/dankmaterialshell/installation)**
---
## What You Get
**Dynamic Theming**
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (like vscode, vscodium), and more with [matugen](https://github.com/InioX/matugen) and [dank16](https://github.com/AvengeMedia/danklinux/blob/master/internal/dank16/dank16.go).
**System Monitoring**
Real-time CPU, RAM, GPU metrics and temps with [dgop](https://github.com/AvengeMedia/dgop). Full process list with search and management.
**Powerful Launcher**
Spotlight-style search for apps, files (via [dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, commands - extensible with plugins.
**Control Center**
Network, Bluetooth, audio devices, display settings, night mode - all in one clean interface.
**Smart Notifications**
Notification center with grouping, rich text support, and keyboard navigation.
**Media Integration**
MPRIS player controls, calendar sync, weather widgets, clipboard history with image previews.
**Complete Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, greeter support.
**Plugin System**
Endless customization with the [plugin registry](https://plugins.danklinux.com).
**TL;DR** - One shell replaces waybar, swaylock, swayidle, mako, fuzzel, polkit and everything else you normally piece together to create a linux desktop.
---
## Supported Compositors
DankMaterialShell works best with **[niri](https://github.com/YaLTeR/niri)**, **[Hyprland](https://hyprland.org/)**, **[sway](https://swaywm.org/)**, and **[dwl/MangoWC](https://github.com/DreamMaoMao/mangowc)**. - with full workspace switching, overview integration, and monitor management.
Other Wayland compositors work too, just with a reduced feature set.
**[Compositor configuration guide →](https://danklinux.com/docs/dankmaterialshell/compositors)**
---
## Keybinds & IPC
Control everything from the command line or keybinds:
```bash
dms ipc call spotlight toggle
dms ipc call audio setvolume 50
dms ipc call wallpaper set /path/to/image.jpg
dms ipc call theme toggle
```
**[Full keybind and IPC documentation →](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)**
---
## Theming
DankMaterialShell automatically generates color schemes from your wallpaper or theme and applies them to GTK, Qt, terminals, and more.
DMS is not opinionated or forcing these themes - they are created as optional themes you can enable. You can refer to the documentation if you want to use them:
**Application theming:** [GTK, Qt, Firefox, terminals, vscode+vscodium →](https://danklinux.com/docs/dankmaterialshell/application-themes)
**Custom themes:** [Create your own color schemes →](https://danklinux.com/docs/dankmaterialshell/custom-themes)
---
## Plugins
Extend dms with the plugin system. Browse community plugins at [plugins.danklinux.com](https://plugins.danklinux.com).
**[Plugin development guide →](https://danklinux.com/docs/dankmaterialshell/plugins-overview)**
---
## Documentation
**Website:** [danklinux.com](https://danklinux.com)
**Docs:** [danklinux.com/docs](https://danklinux.com/docs)
**Support:** [Ko-fi](https://ko-fi.com/avengemediallc)
---
## Contributing
Contributions welcome! Bug fixes, new widgets, theme improvements, or docs - it all helps.
**Contributing Code:**
1. Fork the repository
2. Set up the development environment
3. Make your changes
4. Open a pull request
**Contributing Documentation:**
1. Fork the [DankLinux-Docs](https://github.com/AvengeMedia/DankLinux-Docs) repository
2. Update files in the `docs/` folder
3. Open a pull request
### Development Setup
**Requirements:**
- `python3` - Translation management
**Git Hooks:**
Enable the pre-commit hook to check translation sync status:
```bash
git config core.hooksPath .githooks
```
**Translation Workflow**
Set POEditor credentials:
```bash
export POEDITOR_API_TOKEN="your_api_token"
export POEDITOR_PROJECT_ID="your_project_id"
```
Sync translations before committing:
```bash
python3 scripts/i18nsync.py sync
```
This script:
- Extracts strings from QML files
- Uploads changed English terms to POEditor
- Downloads updated translations from POEditor
- Stages all changes for commit
The pre-commit hook will block commits if translations are out of sync and remind you to run the sync script.
Without POEditor credentials, the hook is skipped and commits proceed normally.
Check the [issues](https://github.com/AvengeMedia/DankMaterialShell/issues) or join the community.
---
## Credits
- [quickshell](https://quickshell.org/) the core of what makes a shell like this possible.
- [niri](https://github.com/YaLTeR/niri) for the awesome scrolling compositor.
- [Ly-sec](http://github.com/ly-sec) for awesome wallpaper effects among other things from [Noctalia](https://github.com/noctalia-dev/noctalia-shell)
- [soramanew](https://github.com/soramanew) who built [caelestia](https://github.com/caelestia-dots/shell) which served as inspiration and guidance for many dank widgets.
- [end-4](https://github.com/end-4) for [dots-hyprland](https://github.com/end-4/dots-hyprland) which also served as inspiration and guidance for many dank widgets.

21
backend/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Avenge Media LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

310
backend/README.md Normal file
View File

@@ -0,0 +1,310 @@
<div align="center">
<a href="https://danklinux.com">
<img src="assets/danklogo.svg" alt="Dank Linux" width="200">
</a>
### dms CLI & Backend + dankinstall
[![Documentation](https://img.shields.io/badge/docs-danklinux.com-9ccbfb?style=for-the-badge&labelColor=101418)](https://danklinux.com/docs)
[![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/danklinux?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/backend/releases)
[![GitHub License](https://img.shields.io/badge/license-MIT-b9c8da?style=for-the-badge&labelColor=101418)](https://github.com/AvengeMedia/DankMaterialShell/backend/blob/master/LICENSE)
</div>
---
A monorepo for dankinstall and dms (cli+go backend), a modern desktop suite for Wayland compositors.
**[Full documentation →](https://danklinux.com/docs)**
- **dms** DankMaterialShell (cli + go backend)
- The backend side of dms, provides APIs for the desktop and a management CLI.
- Shared dbus connection for networking (NetworkManager, iwd), loginctl, accountsservice, cups, and other interfaces.
- Implements wayland protocols
- wlr-gamma-control-unstable-v1 (for night mode/gamma control)
- dwl-ipc-unstable-v2 (for dwl/MangoWC integration)
- ext-workspace-v1 (for workspace integrations)
- wlr-output-management-unstable-v1
- Exposes a json API over unix socket for interaction with these interfaces
- Provides plugin management APIs for the shell
- CUPS integration for printer management
- ddc/ci protocol implementation
- Allows controlling brightness of external monitors, like `ddcutil`
- backlight + led control integration
- Allows controlling backlight of integrated displays, or LED devices
- Uses `login1` when available, else falls back to sysfs writes.
- Optionally provides `update` interface - depending on build inputs.
- This is intended to be disabled when packaged as part of distribution packages.
- **dankinstall** Installs the Dank Linux suite for [niri](https://github.com/YaLTeR/niri) and/or [Hyprland](https://hypr.land)
- Features the [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell)
- Which features a complete desktop experience with wallpapers, auto theming, notifications, lock screen, etc.
- Offers up solid out of the box configurations as usable, featured starting points.
- Can be installed if you already have niri/Hyprland configured
- Will allow you to keep your existing config, or replace with Dank ones (existing configs always backed up though)
# dms cli & backend
Written in Go, provides a suite of APIs over unix socket via [godbus](https://github.com/godbus/dbus) and Wayland protocols. All features listed above are exposed over the socket API.
*Run `dms debug-srv` to start the socket service in standalone mode and see available APIs*
**CLI Commands:**
- `dms run [-d]` - Start shell (optionally as daemon)
- `dms restart` / `dms kill` - Manage running processes
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
- `dms plugins [install|browse|search]` - Plugin management
- `dms brightness [list|set]` - Control display/monitor brightness
- `dms update` - Update DMS and dependencies (disabled in distro packages)
- `dms greeter install` - Install greetd greeter (disabled in distro packages)
## Build & Install
To build the dms CLI (Requires Go 1.24+):
### For distribution package maintainers
This produces a build without the `update` or `greeter` functionality, which are intended for manual installation.
```bash
make dist
```
Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64`
### Manual Install
```bash
# Installs to /usr/local/bin/dms
make && sudo make install
```
### Wayland Protocol Bindings
The gamma control functionality uses Wayland protocol bindings generated from the protocol XML definition. To regenerate the Go bindings from `internal/proto/xml/wlr-gamma-control-unstable-v1.xml`:
```bash
go install github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest
go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
-pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go
```
This is only needed if modifying the protocol or updating to a newer version.
# Dank Linux/dankinstall
Installs compositor, dms, terminal, and some optional dependencies - along with a default compositor & terminal configuration.
## Quickstart
```bash
curl -fsSL https://install.danklinux.com | sh
```
*Alternatively, download the latest [release](https://github.com/AvengeMedia/DankMaterialShell/backend/releases)*
## Supported Distributions
**Note on Greeter**: dankinstall does not install a greeter automatically.
- To install the dms greeter, run `dms greeter install` after installation.
- Then you can disable any existing greeter, if present, and run `sudo systemctl enable --now greetd`
### Arch Linux & Derivatives
**Supported:** Arch, ArchARM, Archcraft, CachyOS, EndeavourOS, Manjaro
**Special Notes:**
- Uses native `pacman` for system packages
- AUR packages are built manually using `makepkg` (no AUR helper dependency)
- **Recommendations**
- Use NetworkManager to manage networking
- If using archinstall, you can choose `minimal` for profile, and `NetworkManager` under networking.
**Package Sources:**
| Package | Source | Notes |
|---------|---------|-------|
| System packages (git, jq, etc.) | Official repos | Via `pacman` |
| quickshell | AUR | Built from source |
| matugen | AUR (`matugen-bin`) | Pre-compiled binary |
| dgop | Official repos | Available in Extra repository |
| niri | Official repos (`niri`) | Latest niri |
| hyprland | Official repos | Available in Extra repository |
| DankMaterialShell | Manual | Git clone to `~/.config/quickshell/dms` |
### Fedora & Derivatives
**Supported:** Fedora, Nobara, Fedora Asahi Remix
**Special Notes:**
- Requires `dnf-plugins-core` for COPR repository support
- Automatically enables required COPR repositories
- All COPR repos are enabled with automatic acceptance
- **Editions** dankinstall is tested on "Workstation Edition", but probably works fine on any fedora flavor. Report issues if anything doesn't work.
- [Fedora Asahi Remix](https://asahilinux.org/fedora/) hasn't been tested, but presumably it should work fine as all of the dependencies should provide arm64 variants.
**Package Sources:**
| Package | Source | Notes |
|---------|---------|-------|
| System packages | Official repos | Via `dnf` |
| quickshell | COPR | `avengemedia/danklinux` |
| matugen | COPR | `avengemedia/danklinux` |
| dgop | Manual | Built from source with Go |
| cliphist | COPR | `avengemedia/danklinux` |
| ghostty | COPR | `avengemedia/danklinux` |
| hyprland | COPR | `solopasha/hyprland` |
| niri | COPR | `yalter/niri` |
| DankMaterialShell | COPR | `avengemedia/dms` |
### Ubuntu
**Supported:** Ubuntu 25.04+
**Special Notes:**
- Requires PPA support via `software-properties-common`
- Go installed from PPA for building manual packages
- Most packages require manual building due to limited repository availability
- This means the install can be quite slow, as many need to be compiled from source.
- niri is packages as a `.deb` so it can be managed via `apt`
- Automatic PPA repository addition and package list updates
**Package Sources:**
| Package | Source | Notes |
|---------|---------|-------|
| System packages | Official repos | Via `apt` |
| quickshell | Manual | Built from source with cmake |
| matugen | Manual | Built from source with Go |
| dgop | Manual | Built from source with Go |
| hyprland | PPA | `ppa:cppiber/hyprland` |
| hyprpicker | PPA | `ppa:cppiber/hyprland` |
| niri | Manual | Built from source with Rust |
| Go compiler | PPA | `ppa:longsleep/golang-backports` |
| DankMaterialShell | Manual | Git clone to `~/.config/quickshell/dms` |
### Debian
**Supported:** Debian 13+ (Trixie)
**Special Notes:**
- **niri only** - Debian does not support Hyprland currently, only niri.
- Most packages require manual building due to limited repository availability
- This means the install can be quite slow, as many need to be compiled from source.
- niri is packages as a `.deb` so it can be managed via `apt`
**Package Sources:**
| Package | Source | Notes |
|---------|---------|-------|
| System packages | Official repos | Via `apt` |
| quickshell | Manual | Built from source with cmake |
| matugen | Manual | Built from source with Go |
| dgop | Manual | Built from source with Go |
| niri | Manual | Built from source with Rust |
| DankMaterialShell | Manual | Git clone to `~/.config/quickshell/dms` |
### openSUSE Tumbleweed
**Special Notes:**
- Most packages available in standard repos, minimal manual building required
- quickshell and matugen require building from source
**Package Sources:**
| Package | Source | Notes |
|---------|---------|-------|
| System packages (git, jq, etc.) | Official repos | Via `zypper` |
| hyprland | Official repos | Available in standard repos |
| niri | Official repos | Available in standard repos |
| xwayland-satellite | Official repos | For niri X11 app support |
| ghostty | Official repos | Latest terminal emulator |
| kitty, alacritty | Official repos | Alternative terminals |
| grim, slurp, hyprpicker | Official repos | Wayland screenshot utilities |
| wl-clipboard | Official repos | Via `wl-clipboard` package |
| cliphist | Official repos | Clipboard manager |
| quickshell | Manual | Built from source with cmake + openSUSE flags |
| matugen | Manual | Built from source with Rust |
| dgop | Manual | Built from source with Go |
| DankMaterialShell | Manual | Git clone to `~/.config/quickshell/dms` |
### Gentoo
**Special Notes:**
- Gentoo installs are **highly variable** and user-specific, success is not guaranteed.
- `dankinstall` is most likely to succeed on a fresh stage3/systemd system
- Uses Portage package manager with GURU overlay for additional packages
- Automatically configures global USE flags in `/etc/portage/make.conf`
- Will create or append to your existing USE flags.
- Automatically configures package-specific USE flags in `/etc/portage/package.use/danklinux`
- Unmasks packages as-needed with architecture keywords in `/etc/portage/package.accept_keywords/danklinux`
- Supports both `amd64` and `arm64` architectures dynamically
- If not using bin packages, prepare for long compilation times
- **Ghostty** is removed from the options, due to extremely long compilation time of its
**Package Sources:**
| Package | Source | Notes |
|---------|---------|-------|
| System packages (git, etc.) | Official repos | Via `emerge` |
| niri | GURU overlay | With dbus and screencast USE flags |
| hyprland | Official repos (GURU for -git) | Depends on variant selection, with X USE flag |
| quickshell | GURU overlay | Always uses live ebuild (`**` keywords), full feature set |
| matugen | GURU overlay | Color generation tool |
| cliphist | GURU overlay | Clipboard manager |
| hyprpicker | GURU overlay | Color picker for Hyprland |
| xdg-desktop-portal-gtk | Official repos | With wayland and X USE flags |
| mate-polkit | Official repos | PolicyKit authentication agent |
| accountsservice | Official repos | User account management |
| dgop | Manual | Built from source with Go |
| xwayland-satellite | Manual | For niri X11 app support |
| grimblast | Manual | For Hyprland screenshot utility |
| DankMaterialShell | Manual | Git clone to `~/.config/quickshell/dms` |
**Global USE Flags:**
`X dbus udev alsa policykit jpeg png webp gif tiff svg brotli gdbm accessibility gtk qt6 egl gbm`
**Package-Specific USE Flags:**
- `sys-apps/xdg-desktop-portal-gtk`: wayland X
- `gui-wm/niri`: dbus screencast
- `gui-wm/hyprland`: X
- `dev-qt/qtbase`: wayland opengl vulkan widgets
- `dev-qt/qtdeclarative`: opengl vulkan
- `media-libs/mesa`: opengl vulkan
- `gui-apps/quickshell`: breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth
### NixOS (Not supported by Dank Linux, but with Flake)
NixOS users should use the [dms flake](https://github.com/AvengeMedia/DankMaterialShell/tree/master?tab=readme-ov-file#nixos---via-home-manager)
## Manual Package Building
The installer handles manual package building for packages not available in repositories:
### quickshell (Ubuntu, Debian, openSUSE)
- Built from source using cmake
- Requires Qt6 development libraries
- Automatically handles build dependencies
- **openSUSE:** Uses special CFLAGS with rpm optflags and wayland include path
### matugen (Ubuntu, Debian, Fedora, openSUSE)
- Built from Rust source
- Requires cargo and rust toolchain
- Installed to `/usr/local/bin`
### dgop (All distros)
- Built from Go source
- Simple dependency-free build
- Installed to `/usr/local/bin`
### niri (Ubuntu, Debian)
- Built from Rust source
- Requires cargo and rust toolchain
- Complex build with multiple dependencies
## Commands
### dankinstall
Main installer with interactive TUI for initial setup
### dms
Management interface for DankMaterialShell:
- `dms` - Interactive management TUI
- `dms run` - Start interactive shell
- `dms run -d` - Start shell as daemon
- `dms restart` - Restart running DMS shell
- `dms kill` - Kill running DMS shell processes
- `dms ipc <command>` - Send IPC commands to running shell

46
backend/assets/dank.svg Normal file
View File

@@ -0,0 +1,46 @@
<svg viewBox="0 0 136 50" xmlns="http://www.w3.org/2000/svg">
<!-- D -->
<rect x="0" y="5" width="24" height="8" fill="#CCBEFF"/>
<rect x="0" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="20" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="0" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="20" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="0" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="20" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="0" y="37" width="24" height="8" fill="#CCBEFF"/>
<!-- A -->
<rect x="36" y="5" width="20" height="8" fill="#CCBEFF"/>
<rect x="32" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="52" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="32" y="21" width="28" height="8" fill="#CCBEFF"/>
<rect x="32" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="52" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="32" y="37" width="8" height="8" fill="#CCBEFF"/>
<rect x="52" y="37" width="8" height="8" fill="#CCBEFF"/>
<!-- N -->
<rect x="64" y="5" width="12" height="8" fill="#CCBEFF"/>
<rect x="92" y="5" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="13" width="16" height="8" fill="#CCBEFF"/>
<rect x="92" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="76" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="92" y="21" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="80" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="92" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="64" y="37" width="8" height="8" fill="#CCBEFF"/>
<rect x="84" y="37" width="16" height="8" fill="#CCBEFF"/>
<!-- K -->
<rect x="104" y="5" width="8" height="8" fill="#CCBEFF"/>
<rect x="124" y="5" width="8" height="8" fill="#CCBEFF"/>
<rect x="104" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="120" y="13" width="8" height="8" fill="#CCBEFF"/>
<rect x="104" y="21" width="20" height="8" fill="#CCBEFF"/>
<rect x="104" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="120" y="29" width="8" height="8" fill="#CCBEFF"/>
<rect x="104" y="37" width="8" height="8" fill="#CCBEFF"/>
<rect x="124" y="37" width="8" height="8" fill="#CCBEFF"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

38
backend/build_dankinstall.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
set -euo pipefail
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Get latest version tag
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo -e "${GREEN}Building dankinstall ${VERSION}${NC}"
# Create bin directory if it doesn't exist
mkdir -p bin
# Build for each architecture
for ARCH in amd64 arm64; do
echo -e "${BLUE}Building for ${ARCH}...${NC}"
cd cmd/dankinstall
GOOS=linux CGO_ENABLED=0 GOARCH=${ARCH} \
go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \
-o ../../bin/dankinstall-${ARCH}
cd ../..
# Compress
gzip -9 -k -f bin/dankinstall-${ARCH}
# Generate checksum
(cd bin && sha256sum dankinstall-${ARCH}.gz > dankinstall-${ARCH}.gz.sha256)
echo -e "${GREEN}✓ Built bin/dankinstall-${ARCH}.gz${NC}"
done
echo -e "${GREEN}Done! Files ready in bin/:${NC}"
ls -lh bin/dankinstall-*

View File

@@ -0,0 +1,303 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/server/brightness"
"github.com/spf13/cobra"
)
var brightnessCmd = &cobra.Command{
Use: "brightness",
Short: "Control device brightness",
Long: "Control brightness for backlight and LED devices (use --ddc to include DDC/I2C monitors)",
}
var brightnessListCmd = &cobra.Command{
Use: "list",
Short: "List all brightness devices",
Long: "List all available brightness devices with their current values",
Run: runBrightnessList,
}
var brightnessSetCmd = &cobra.Command{
Use: "set <device_id> <percent>",
Short: "Set brightness for a device",
Long: "Set brightness percentage (0-100) for a specific device",
Args: cobra.ExactArgs(2),
Run: runBrightnessSet,
}
var brightnessGetCmd = &cobra.Command{
Use: "get <device_id>",
Short: "Get brightness for a device",
Long: "Get current brightness percentage for a specific device",
Args: cobra.ExactArgs(1),
Run: runBrightnessGet,
}
func init() {
brightnessListCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)")
brightnessSetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)")
brightnessSetCmd.Flags().Bool("exponential", false, "Use exponential brightness scaling")
brightnessSetCmd.Flags().Float64("exponent", 1.2, "Exponent for exponential scaling (default 1.2)")
brightnessGetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)")
brightnessCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableSubCommands}}
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`)
brightnessListCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`)
brightnessSetCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`)
brightnessGetCmd.SetHelpTemplate(`{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`)
brightnessCmd.AddCommand(brightnessListCmd, brightnessSetCmd, brightnessGetCmd)
}
func runBrightnessList(cmd *cobra.Command, args []string) {
includeDDC, _ := cmd.Flags().GetBool("ddc")
allDevices := []brightness.Device{}
sysfs, err := brightness.NewSysfsBackend()
if err != nil {
log.Debugf("Failed to initialize sysfs backend: %v", err)
} else {
devices, err := sysfs.GetDevices()
if err != nil {
log.Debugf("Failed to get sysfs devices: %v", err)
} else {
allDevices = append(allDevices, devices...)
}
}
if includeDDC {
ddc, err := brightness.NewDDCBackend()
if err != nil {
fmt.Printf("Warning: Failed to initialize DDC backend: %v\n", err)
} else {
time.Sleep(100 * time.Millisecond)
devices, err := ddc.GetDevices()
if err != nil {
fmt.Printf("Warning: Failed to get DDC devices: %v\n", err)
} else {
allDevices = append(allDevices, devices...)
}
ddc.Close()
}
}
if len(allDevices) == 0 {
fmt.Println("No brightness devices found")
return
}
maxIDLen := len("Device")
maxNameLen := len("Name")
for _, dev := range allDevices {
if len(dev.ID) > maxIDLen {
maxIDLen = len(dev.ID)
}
if len(dev.Name) > maxNameLen {
maxNameLen = len(dev.Name)
}
}
idPad := maxIDLen + 2
namePad := maxNameLen + 2
fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness")
sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15
for i := 0; i < sepLen; i++ {
fmt.Print("─")
}
fmt.Println()
for _, device := range allDevices {
fmt.Printf("%-*s %-12s %-*s %3d%%\n",
idPad,
device.ID,
device.Class,
namePad,
device.Name,
device.CurrentPercent,
)
}
}
func runBrightnessSet(cmd *cobra.Command, args []string) {
deviceID := args[0]
var percent int
if _, err := fmt.Sscanf(args[1], "%d", &percent); err != nil {
log.Fatalf("Invalid percent value: %s", args[1])
}
if percent < 0 || percent > 100 {
log.Fatalf("Percent must be between 0 and 100")
}
includeDDC, _ := cmd.Flags().GetBool("ddc")
exponential, _ := cmd.Flags().GetBool("exponential")
exponent, _ := cmd.Flags().GetFloat64("exponent")
// For backlight/leds devices, try logind backend first (requires D-Bus connection)
parts := strings.SplitN(deviceID, ":", 2)
if len(parts) == 2 && (parts[0] == "backlight" || parts[0] == "leds") {
subsystem := parts[0]
name := parts[1]
// Initialize backends needed for logind approach
sysfs, err := brightness.NewSysfsBackend()
if err != nil {
log.Debugf("NewSysfsBackend failed: %v", err)
} else {
logind, err := brightness.NewLogindBackend()
if err != nil {
log.Debugf("NewLogindBackend failed: %v", err)
} else {
defer logind.Close()
// Get device info to convert percent to value
dev, err := sysfs.GetDevice(deviceID)
if err == nil {
// Calculate hardware value using the same logic as Manager.setViaSysfsWithLogind
value := sysfs.PercentToValueWithExponent(percent, dev, exponential, exponent)
// Call logind with hardware value
if err := logind.SetBrightness(subsystem, name, uint32(value)); err == nil {
log.Debugf("set %s to %d%% (%d) via logind", deviceID, percent, value)
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
} else {
log.Debugf("logind.SetBrightness failed: %v", err)
}
} else {
log.Debugf("sysfs.GetDeviceByID failed: %v", err)
}
}
}
}
// Fallback to direct sysfs (requires write permissions)
sysfs, err := brightness.NewSysfsBackend()
if err == nil {
if err := sysfs.SetBrightnessWithExponent(deviceID, percent, exponential, exponent); err == nil {
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
}
log.Debugf("sysfs.SetBrightness failed: %v", err)
} else {
log.Debugf("NewSysfsBackend failed: %v", err)
}
// Try DDC if requested
if includeDDC {
ddc, err := brightness.NewDDCBackend()
if err == nil {
defer ddc.Close()
time.Sleep(100 * time.Millisecond)
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
return
}
log.Debugf("ddc.SetBrightness failed: %v", err)
} else {
log.Debugf("NewDDCBackend failed: %v", err)
}
}
log.Fatalf("Failed to set brightness for device: %s", deviceID)
}
func runBrightnessGet(cmd *cobra.Command, args []string) {
deviceID := args[0]
includeDDC, _ := cmd.Flags().GetBool("ddc")
allDevices := []brightness.Device{}
sysfs, err := brightness.NewSysfsBackend()
if err == nil {
devices, err := sysfs.GetDevices()
if err == nil {
allDevices = append(allDevices, devices...)
}
}
if includeDDC {
ddc, err := brightness.NewDDCBackend()
if err == nil {
defer ddc.Close()
time.Sleep(100 * time.Millisecond)
devices, err := ddc.GetDevices()
if err == nil {
allDevices = append(allDevices, devices...)
}
}
}
for _, device := range allDevices {
if device.ID == deviceID {
fmt.Printf("%s: %d%% (%d/%d)\n",
device.ID,
device.CurrentPercent,
device.Current,
device.Max,
)
return
}
}
log.Fatalf("Device not found: %s", deviceID)
}

View File

@@ -0,0 +1,375 @@
package main
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/server"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version information",
Run: runVersion,
}
var runCmd = &cobra.Command{
Use: "run",
Short: "Launch quickshell with DMS configuration",
Long: "Launch quickshell with DMS configuration (qs -c dms)",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session")
if daemon {
runShellDaemon(session)
} else {
runShellInteractive(session)
}
},
}
var restartCmd = &cobra.Command{
Use: "restart",
Short: "Restart quickshell with DMS configuration",
Long: "Kill existing DMS shell processes and restart quickshell with DMS configuration",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
restartShell()
},
}
var restartDetachedCmd = &cobra.Command{
Use: "restart-detached <pid>",
Hidden: true,
Args: cobra.ExactArgs(1),
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runDetachedRestart(args[0])
},
}
var killCmd = &cobra.Command{
Use: "kill",
Short: "Kill running DMS shell processes",
Long: "Kill all running quickshell processes with DMS configuration",
Run: func(cmd *cobra.Command, args []string) {
killShell()
},
}
var ipcCmd = &cobra.Command{
Use: "ipc",
Short: "Send IPC commands to running DMS shell",
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runShellIPCCommand(args)
},
}
var debugSrvCmd = &cobra.Command{
Use: "debug-srv",
Short: "Start the debug server",
Long: "Start the Unix socket debug server for DMS",
Run: func(cmd *cobra.Command, args []string) {
if err := startDebugServer(); err != nil {
log.Fatalf("Error starting debug server: %v", err)
}
},
}
var pluginsCmd = &cobra.Command{
Use: "plugins",
Short: "Manage DMS plugins",
Long: "Browse and manage DMS plugins from the registry",
}
var pluginsBrowseCmd = &cobra.Command{
Use: "browse",
Short: "Browse available plugins",
Long: "Browse available plugins from the DMS plugin registry",
Run: func(cmd *cobra.Command, args []string) {
if err := browsePlugins(); err != nil {
log.Fatalf("Error browsing plugins: %v", err)
}
},
}
var pluginsListCmd = &cobra.Command{
Use: "list",
Short: "List installed plugins",
Long: "List all installed DMS plugins",
Run: func(cmd *cobra.Command, args []string) {
if err := listInstalledPlugins(); err != nil {
log.Fatalf("Error listing plugins: %v", err)
}
},
}
var pluginsInstallCmd = &cobra.Command{
Use: "install <plugin-id>",
Short: "Install a plugin by ID",
Long: "Install a DMS plugin from the registry using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := installPluginCLI(args[0]); err != nil {
log.Fatalf("Error installing plugin: %v", err)
}
},
}
var pluginsUninstallCmd = &cobra.Command{
Use: "uninstall <plugin-id>",
Short: "Uninstall a plugin by ID",
Long: "Uninstall a DMS plugin using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := uninstallPluginCLI(args[0]); err != nil {
log.Fatalf("Error uninstalling plugin: %v", err)
}
},
}
func runVersion(cmd *cobra.Command, args []string) {
printASCII()
fmt.Printf("%s\n", Version)
}
func startDebugServer() error {
return server.Start(true)
}
func browsePlugins() error {
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
fmt.Println("Fetching plugin registry...")
pluginList, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
if len(pluginList) == 0 {
fmt.Println("No plugins found in registry.")
return nil
}
fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList))
for _, plugin := range pluginList {
installed, _ := manager.IsInstalled(plugin)
installedMarker := ""
if installed {
installedMarker = " [Installed]"
}
fmt.Printf(" %s%s\n", plugin.Name, installedMarker)
fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author)
fmt.Printf(" Description: %s\n", plugin.Description)
fmt.Printf(" Repository: %s\n", plugin.Repo)
if len(plugin.Capabilities) > 0 {
fmt.Printf(" Capabilities: %s\n", strings.Join(plugin.Capabilities, ", "))
}
if len(plugin.Compositors) > 0 {
fmt.Printf(" Compositors: %s\n", strings.Join(plugin.Compositors, ", "))
}
if len(plugin.Dependencies) > 0 {
fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", "))
}
fmt.Println()
}
return nil
}
func listInstalledPlugins() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installedNames, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
if len(installedNames) == 0 {
fmt.Println("No plugins installed.")
return nil
}
allPlugins, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
pluginMap := make(map[string]plugins.Plugin)
for _, p := range allPlugins {
pluginMap[p.ID] = p
}
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok {
fmt.Printf(" %s\n", plugin.Name)
fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author)
fmt.Println()
} else {
fmt.Printf(" %s (not in registry)\n\n", id)
}
}
return nil
}
func installPluginCLI(idOrName string) error {
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
pluginList, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
// First, try to find by ID (preferred method)
var plugin *plugins.Plugin
for _, p := range pluginList {
if p.ID == idOrName {
plugin = &p
break
}
}
// Fallback to name for backward compatibility
if plugin == nil {
for _, p := range pluginList {
if p.Name == idOrName {
plugin = &p
break
}
}
}
if plugin == nil {
return fmt.Errorf("plugin not found: %s", idOrName)
}
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if installed {
return fmt.Errorf("plugin already installed: %s", plugin.Name)
}
fmt.Printf("Installing plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Install(*plugin); err != nil {
return fmt.Errorf("failed to install plugin: %w", err)
}
fmt.Printf("Plugin installed successfully: %s\n", plugin.Name)
return nil
}
func uninstallPluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, err := registry.List()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
// First, try to find by ID (preferred method)
var plugin *plugins.Plugin
for _, p := range pluginList {
if p.ID == idOrName {
plugin = &p
break
}
}
// Fallback to name for backward compatibility
if plugin == nil {
for _, p := range pluginList {
if p.Name == idOrName {
plugin = &p
break
}
}
}
if plugin == nil {
return fmt.Errorf("plugin not found: %s", idOrName)
}
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
fmt.Printf("Uninstalling plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Uninstall(*plugin); err != nil {
return fmt.Errorf("failed to uninstall plugin: %w", err)
}
fmt.Printf("Plugin uninstalled successfully: %s\n", plugin.Name)
return nil
}
// getCommonCommands returns the commands available in all builds
func getCommonCommands() []*cobra.Command {
return []*cobra.Command{
versionCmd,
runCmd,
restartCmd,
restartDetachedCmd,
killCmd,
ipcCmd,
debugSrvCmd,
pluginsCmd,
dank16Cmd,
brightnessCmd,
keybindsCmd,
greeterCmd,
setupCmd,
}
}

View File

@@ -0,0 +1,90 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/dank16"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/spf13/cobra"
)
var dank16Cmd = &cobra.Command{
Use: "dank16 <hex_color>",
Short: "Generate Base16 color palettes",
Long: "Generate Base16 color palettes from a color with support for various output formats",
Args: cobra.ExactArgs(1),
Run: runDank16,
}
func init() {
dank16Cmd.Flags().Bool("light", false, "Generate light theme variant")
dank16Cmd.Flags().Bool("json", false, "Output in JSON format")
dank16Cmd.Flags().Bool("kitty", false, "Output in Kitty terminal format")
dank16Cmd.Flags().Bool("foot", false, "Output in Foot terminal format")
dank16Cmd.Flags().Bool("alacritty", false, "Output in Alacritty terminal format")
dank16Cmd.Flags().Bool("ghostty", false, "Output in Ghostty terminal format")
dank16Cmd.Flags().String("vscode-enrich", "", "Enrich existing VSCode theme file with terminal colors")
dank16Cmd.Flags().String("background", "", "Custom background color")
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
}
func runDank16(cmd *cobra.Command, args []string) {
primaryColor := args[0]
if !strings.HasPrefix(primaryColor, "#") {
primaryColor = "#" + primaryColor
}
isLight, _ := cmd.Flags().GetBool("light")
isJson, _ := cmd.Flags().GetBool("json")
isKitty, _ := cmd.Flags().GetBool("kitty")
isFoot, _ := cmd.Flags().GetBool("foot")
isAlacritty, _ := cmd.Flags().GetBool("alacritty")
isGhostty, _ := cmd.Flags().GetBool("ghostty")
vscodeEnrich, _ := cmd.Flags().GetString("vscode-enrich")
background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast")
if background != "" && !strings.HasPrefix(background, "#") {
background = "#" + background
}
contrastAlgo = strings.ToLower(contrastAlgo)
if contrastAlgo != "dps" && contrastAlgo != "wcag" {
log.Fatalf("Invalid contrast algorithm: %s (must be 'dps' or 'wcag')", contrastAlgo)
}
opts := dank16.PaletteOptions{
IsLight: isLight,
Background: background,
UseDPS: contrastAlgo == "dps",
}
colors := dank16.GeneratePalette(primaryColor, opts)
if vscodeEnrich != "" {
data, err := os.ReadFile(vscodeEnrich)
if err != nil {
log.Fatalf("Error reading file: %v", err)
}
enriched, err := dank16.EnrichVSCodeTheme(data, colors)
if err != nil {
log.Fatalf("Error enriching theme: %v", err)
}
fmt.Println(string(enriched))
} else if isJson {
fmt.Print(dank16.GenerateJSON(colors))
} else if isKitty {
fmt.Print(dank16.GenerateKittyTheme(colors))
} else if isFoot {
fmt.Print(dank16.GenerateFootTheme(colors))
} else if isAlacritty {
fmt.Print(dank16.GenerateAlacrittyTheme(colors))
} else if isGhostty {
fmt.Print(dank16.GenerateGhosttyTheme(colors))
} else {
fmt.Print(dank16.GenerateGhosttyTheme(colors))
}
}

View File

@@ -0,0 +1,488 @@
//go:build !distro_binary
package main
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/version"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update DankMaterialShell to the latest version",
Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runUpdate()
},
}
var updateCheckCmd = &cobra.Command{
Use: "check",
Short: "Check if updates are available for DankMaterialShell",
Long: "Check for available updates without performing the actual update",
Run: func(cmd *cobra.Command, args []string) {
runUpdateCheck()
},
}
func runUpdateCheck() {
fmt.Println("Checking for DankMaterialShell updates...")
fmt.Println()
versionInfo, err := version.GetDMSVersionInfo()
if err != nil {
log.Fatalf("Error checking for updates: %v", err)
}
fmt.Printf("Current version: %s\n", versionInfo.Current)
fmt.Printf("Latest version: %s\n", versionInfo.Latest)
fmt.Println()
if versionInfo.HasUpdate {
fmt.Println("✓ Update available!")
fmt.Println()
fmt.Println("Run 'dms update' to install the latest version.")
os.Exit(0)
} else {
fmt.Println("✓ You are running the latest version.")
os.Exit(0)
}
}
func runUpdate() {
osInfo, err := distros.GetOSInfo()
if err != nil {
log.Fatalf("Error detecting OS: %v", err)
}
config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists {
log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID)
}
var updateErr error
switch config.Family {
case distros.FamilyArch:
updateErr = updateArchLinux()
case distros.FamilyNix:
updateErr = updateNixOS()
case distros.FamilySUSE:
updateErr = updateOtherDistros()
default:
updateErr = updateOtherDistros()
}
if updateErr != nil {
if errors.Is(updateErr, errdefs.ErrUpdateCancelled) {
log.Info("Update cancelled.")
return
}
if errors.Is(updateErr, errdefs.ErrNoUpdateNeeded) {
return
}
log.Fatalf("Error updating DMS: %v", updateErr)
}
log.Info("Update complete! Restarting DMS...")
restartShell()
}
func updateArchLinux() error {
homeDir, err := os.UserHomeDir()
if err == nil {
dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
if _, err := os.Stat(dmsPath); err == nil {
return updateOtherDistros()
}
}
var packageName string
if isArchPackageInstalled("dms-shell-bin") {
packageName = "dms-shell-bin"
} else if isArchPackageInstalled("dms-shell-git") {
packageName = "dms-shell-git"
} else {
fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
fmt.Println("Info: Falling back to git-based update method...")
return updateOtherDistros()
}
var helper string
var updateCmd *exec.Cmd
if commandExists("yay") {
helper = "yay"
updateCmd = exec.Command("yay", "-S", packageName)
} else if commandExists("paru") {
helper = "paru"
updateCmd = exec.Command("paru", "-S", packageName)
} else {
fmt.Println("Error: Neither yay nor paru found - please install an AUR helper")
fmt.Println("Info: Falling back to git-based update method...")
return updateOtherDistros()
}
fmt.Printf("This will update DankMaterialShell using %s.\n", helper)
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Printf("\nRunning: %s -S %s\n", helper, packageName)
updateCmd.Stdout = os.Stdout
updateCmd.Stderr = os.Stderr
err = updateCmd.Run()
if err != nil {
fmt.Printf("Error: Failed to update using %s: %v\n", helper, err)
}
fmt.Println("dms successfully updated")
return nil
}
func updateNixOS() error {
fmt.Println("This will update DankMaterialShell using nix profile.")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Println("\nRunning: nix profile upgrade github:AvengeMedia/DankMaterialShell")
updateCmd := exec.Command("nix", "profile", "upgrade", "github:AvengeMedia/DankMaterialShell")
updateCmd.Stdout = os.Stdout
updateCmd.Stderr = os.Stderr
err := updateCmd.Run()
if err != nil {
fmt.Printf("Error: Failed to update using nix profile: %v\n", err)
fmt.Println("Falling back to git-based update method...")
return updateOtherDistros()
}
fmt.Println("dms successfully updated")
return nil
}
func updateOtherDistros() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
return fmt.Errorf("DMS configuration directory not found at %s", dmsPath)
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
versionInfo, err := version.GetDMSVersionInfo()
if err == nil && !versionInfo.HasUpdate {
fmt.Println()
fmt.Printf("Current version: %s\n", versionInfo.Current)
fmt.Printf("Latest version: %s\n", versionInfo.Latest)
fmt.Println()
fmt.Println("✓ You are already running the latest version.")
return errdefs.ErrNoUpdateNeeded
}
fmt.Println("\nThis will update:")
fmt.Println(" 1. The dms binary from GitHub releases")
fmt.Println(" 2. DankMaterialShell configuration using git")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Println("\n=== Updating dms binary ===")
if err := updateDMSBinary(); err != nil {
fmt.Printf("Warning: Failed to update dms binary: %v\n", err)
fmt.Println("Continuing with shell configuration update...")
} else {
fmt.Println("dms binary successfully updated")
}
fmt.Println("\n=== Updating DMS shell configuration ===")
if err := os.Chdir(dmsPath); err != nil {
return fmt.Errorf("failed to change to DMS directory: %w", err)
}
statusCmd := exec.Command("git", "status", "--porcelain")
statusOutput, _ := statusCmd.Output()
hasLocalChanges := len(strings.TrimSpace(string(statusOutput))) > 0
currentRefCmd := exec.Command("git", "symbolic-ref", "-q", "HEAD")
currentRefOutput, _ := currentRefCmd.Output()
onBranch := len(currentRefOutput) > 0
var currentTag string
var currentBranch string
if !onBranch {
tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD")
if tagOutput, err := tagCmd.Output(); err == nil {
currentTag = strings.TrimSpace(string(tagOutput))
}
} else {
branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
if branchOutput, err := branchCmd.Output(); err == nil {
currentBranch = strings.TrimSpace(string(branchOutput))
}
}
fmt.Println("Fetching latest changes...")
fetchCmd := exec.Command("git", "fetch", "origin", "--tags", "--force")
fetchCmd.Stdout = os.Stdout
fetchCmd.Stderr = os.Stderr
if err := fetchCmd.Run(); err != nil {
return fmt.Errorf("failed to fetch changes: %w", err)
}
if currentTag != "" {
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
latestTagOutput, err := latestTagCmd.Output()
if err != nil {
return fmt.Errorf("failed to get latest tag: %w", err)
}
tags := strings.Split(strings.TrimSpace(string(latestTagOutput)), "\n")
if len(tags) == 0 || tags[0] == "" {
return fmt.Errorf("no version tags found")
}
latestTag := tags[0]
if latestTag == currentTag {
fmt.Printf("Already on latest tag: %s\n", currentTag)
return nil
}
fmt.Printf("Current tag: %s\n", currentTag)
fmt.Printf("Latest tag: %s\n", latestTag)
if hasLocalChanges {
fmt.Println("\nWarning: You have local changes in your DMS configuration.")
if offerReclone(dmsPath) {
return nil
}
return errdefs.ErrUpdateCancelled
}
fmt.Printf("Updating to %s...\n", latestTag)
checkoutCmd := exec.Command("git", "checkout", latestTag)
checkoutCmd.Stdout = os.Stdout
checkoutCmd.Stderr = os.Stderr
if err := checkoutCmd.Run(); err != nil {
fmt.Printf("Error: Failed to checkout %s: %v\n", latestTag, err)
if offerReclone(dmsPath) {
return nil
}
return fmt.Errorf("update cancelled")
}
fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag)
return nil
}
if currentBranch == "" {
currentBranch = "master"
}
fmt.Printf("Current branch: %s\n", currentBranch)
if hasLocalChanges {
fmt.Println("\nWarning: You have local changes in your DMS configuration.")
if offerReclone(dmsPath) {
return nil
}
return errdefs.ErrUpdateCancelled
}
pullCmd := exec.Command("git", "pull", "origin", currentBranch)
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
if err := pullCmd.Run(); err != nil {
fmt.Printf("Error: Failed to pull latest changes: %v\n", err)
if offerReclone(dmsPath) {
return nil
}
return fmt.Errorf("update cancelled")
}
fmt.Println("\nUpdate complete!")
return nil
}
func offerReclone(dmsPath string) bool {
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(response)), "y") {
return false
}
timestamp := time.Now().Unix()
backupPath := fmt.Sprintf("%s.backup-%d", dmsPath, timestamp)
fmt.Printf("Backing up current directory to %s...\n", backupPath)
if err := os.Rename(dmsPath, backupPath); err != nil {
fmt.Printf("Error: Failed to backup directory: %v\n", err)
return false
}
fmt.Println("Cloning fresh copy...")
cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil {
fmt.Printf("Error: Failed to clone repository: %v\n", err)
fmt.Printf("Restoring backup...\n")
os.Rename(backupPath, dmsPath)
return false
}
fmt.Printf("Successfully re-cloned repository (backup at %s)\n", backupPath)
return true
}
func confirmUpdate() bool {
fmt.Print("Do you want to proceed with the update? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
fmt.Printf("Error reading input: %v\n", err)
return false
}
response = strings.TrimSpace(strings.ToLower(response))
return response == "y" || response == "yes"
}
func updateDMSBinary() error {
arch := ""
switch strings.ToLower(os.Getenv("HOSTTYPE")) {
case "x86_64", "amd64":
arch = "amd64"
case "aarch64", "arm64":
arch = "arm64"
default:
cmd := exec.Command("uname", "-m")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to detect architecture: %w", err)
}
archStr := strings.TrimSpace(string(output))
switch archStr {
case "x86_64":
arch = "amd64"
case "aarch64":
arch = "arm64"
default:
return fmt.Errorf("unsupported architecture: %s", archStr)
}
}
fmt.Println("Fetching latest release version...")
cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/danklinux/releases/latest")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}
version := ""
for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
version = parts[3]
break
}
}
}
if version == "" {
return fmt.Errorf("could not determine latest version")
}
fmt.Printf("Latest version: %s\n", version)
tempDir, err := os.MkdirTemp("", "dms-update-*")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/backend/releases/download/%s/dms-%s.gz", version, arch)
checksumURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/backend/releases/download/%s/dms-%s.gz.sha256", version, arch)
binaryPath := filepath.Join(tempDir, "dms.gz")
checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
fmt.Println("Downloading dms binary...")
downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download binary: %w", err)
}
fmt.Println("Downloading checksum...")
downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download checksum: %w", err)
}
fmt.Println("Verifying checksum...")
checksumData, err := os.ReadFile(checksumPath)
if err != nil {
return fmt.Errorf("failed to read checksum file: %w", err)
}
expectedChecksum := strings.Fields(string(checksumData))[0]
actualCmd := exec.Command("sha256sum", binaryPath)
actualOutput, err := actualCmd.Output()
if err != nil {
return fmt.Errorf("failed to calculate checksum: %w", err)
}
actualChecksum := strings.Fields(string(actualOutput))[0]
if expectedChecksum != actualChecksum {
return fmt.Errorf("checksum verification failed\nExpected: %s\nGot: %s", expectedChecksum, actualChecksum)
}
fmt.Println("Decompressing binary...")
decompressCmd := exec.Command("gunzip", binaryPath)
if err := decompressCmd.Run(); err != nil {
return fmt.Errorf("failed to decompress binary: %w", err)
}
decompressedPath := filepath.Join(tempDir, "dms")
if err := os.Chmod(decompressedPath, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}
currentPath, err := exec.LookPath("dms")
if err != nil {
return fmt.Errorf("could not find current dms binary: %w", err)
}
fmt.Printf("Installing to %s...\n", currentPath)
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
replaceCmd.Stdin = os.Stdin
replaceCmd.Stdout = os.Stdout
replaceCmd.Stderr = os.Stderr
if err := replaceCmd.Run(); err != nil {
return fmt.Errorf("failed to replace binary: %w", err)
}
return nil
}

View File

@@ -0,0 +1,500 @@
package main
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/spf13/cobra"
)
var greeterCmd = &cobra.Command{
Use: "greeter",
Short: "Manage DMS greeter",
Long: "Manage DMS greeter (greetd)",
}
var greeterInstallCmd = &cobra.Command{
Use: "install",
Short: "Install and configure DMS greeter",
Long: "Install greetd and configure it to use DMS as the greeter interface",
Run: func(cmd *cobra.Command, args []string) {
if err := installGreeter(); err != nil {
log.Fatalf("Error installing greeter: %v", err)
}
},
}
var greeterSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
Run: func(cmd *cobra.Command, args []string) {
if err := syncGreeter(); err != nil {
log.Fatalf("Error syncing greeter: %v", err)
}
},
}
var greeterEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable DMS greeter in greetd config",
Long: "Configure greetd to use DMS as the greeter",
Run: func(cmd *cobra.Command, args []string) {
if err := enableGreeter(); err != nil {
log.Fatalf("Error enabling greeter: %v", err)
}
},
}
var greeterStatusCmd = &cobra.Command{
Use: "status",
Short: "Check greeter sync status",
Long: "Check the status of greeter installation and configuration sync",
Run: func(cmd *cobra.Command, args []string) {
if err := checkGreeterStatus(); err != nil {
log.Fatalf("Error checking greeter status: %v", err)
}
},
}
func installGreeter() error {
fmt.Println("=== DMS Greeter Installation ===")
logFunc := func(msg string) {
fmt.Println(msg)
}
if err := greeter.EnsureGreetdInstalled(logFunc, ""); err != nil {
return err
}
fmt.Println("\nDetecting DMS installation...")
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
fmt.Println("\nDetecting installed compositors...")
compositors := greeter.DetectCompositors()
if len(compositors) == 0 {
return fmt.Errorf("no supported compositors found (niri or Hyprland required)")
}
var selectedCompositor string
if len(compositors) == 1 {
selectedCompositor = compositors[0]
fmt.Printf("✓ Found compositor: %s\n", selectedCompositor)
} else {
var err error
selectedCompositor, err = greeter.PromptCompositorChoice(compositors)
if err != nil {
return err
}
fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor)
}
fmt.Println("\nSetting up dms-greeter group and permissions...")
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
return err
}
fmt.Println("\nCopying greeter files...")
if err := greeter.CopyGreeterFiles(dmsPath, selectedCompositor, logFunc, ""); err != nil {
return err
}
fmt.Println("\nConfiguring greetd...")
if err := greeter.ConfigureGreetd(dmsPath, selectedCompositor, logFunc, ""); err != nil {
return err
}
fmt.Println("\nSynchronizing DMS configurations...")
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
return err
}
fmt.Println("\n=== Installation Complete ===")
fmt.Println("\nTo test the greeter, run:")
fmt.Println(" sudo systemctl start greetd")
fmt.Println("\nTo enable on boot, run:")
fmt.Println(" sudo systemctl enable --now greetd")
return nil
}
func syncGreeter() error {
fmt.Println("=== DMS Greeter Theme Sync ===")
fmt.Println()
logFunc := func(msg string) {
fmt.Println(msg)
}
fmt.Println("Detecting DMS installation...")
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
cacheDir := "/var/cache/dms-greeter"
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
return fmt.Errorf("greeter cache directory not found at %s\nPlease install the greeter first", cacheDir)
}
greeterGroupExists := checkGroupExists("greeter")
if greeterGroupExists {
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
groupsCmd := exec.Command("groups", currentUser.Username)
groupsOutput, err := groupsCmd.Output()
if err != nil {
return fmt.Errorf("failed to check groups: %w", err)
}
inGreeterGroup := strings.Contains(string(groupsOutput), "greeter")
if !inGreeterGroup {
fmt.Println("\n⚠ Warning: You are not in the greeter group.")
fmt.Print("Would you like to add your user to the greeter group? (y/N): ")
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
if response == "y" || response == "yes" {
fmt.Println("\nAdding user to greeter group...")
addUserCmd := exec.Command("sudo", "usermod", "-aG", "greeter", currentUser.Username)
addUserCmd.Stdout = os.Stdout
addUserCmd.Stderr = os.Stderr
if err := addUserCmd.Run(); err != nil {
return fmt.Errorf("failed to add user to greeter group: %w", err)
}
fmt.Println("✓ User added to greeter group")
fmt.Println("⚠ You will need to log out and back in for the group change to take effect")
}
}
}
fmt.Println("\nSetting up permissions and ACLs...")
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
return err
}
fmt.Println("\nSynchronizing DMS configurations...")
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, ""); err != nil {
return err
}
fmt.Println("\n=== Sync Complete ===")
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.")
fmt.Println("The changes will be visible on the next login screen.")
return nil
}
func checkGroupExists(groupName string) bool {
data, err := os.ReadFile("/etc/group")
if err != nil {
return false
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, groupName+":") {
return true
}
}
return false
}
func enableGreeter() error {
fmt.Println("=== DMS Greeter Enable ===")
fmt.Println()
configPath := "/etc/greetd/config.toml"
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return fmt.Errorf("greetd config not found at %s\nPlease install greetd first", configPath)
}
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read greetd config: %w", err)
}
configContent := string(data)
if strings.Contains(configContent, "dms-greeter") {
fmt.Println("✓ Greeter is already configured with dms-greeter")
return nil
}
fmt.Println("Detecting installed compositors...")
compositors := greeter.DetectCompositors()
if commandExists("sway") {
compositors = append(compositors, "sway")
}
if len(compositors) == 0 {
return fmt.Errorf("no supported compositors found (niri, Hyprland, or sway required)")
}
var selectedCompositor string
if len(compositors) == 1 {
selectedCompositor = compositors[0]
fmt.Printf("✓ Found compositor: %s\n", selectedCompositor)
} else {
var err error
selectedCompositor, err = promptCompositorChoice(compositors)
if err != nil {
return err
}
fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor)
}
backupPath := configPath + ".backup"
backupCmd := exec.Command("sudo", "cp", configPath, backupPath)
if err := backupCmd.Run(); err != nil {
return fmt.Errorf("failed to backup config: %w", err)
}
fmt.Printf("✓ Backed up config to %s\n", backupPath)
lines := strings.Split(configContent, "\n")
var newLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
newLines = append(newLines, line)
}
}
wrapperCmd := "dms-greeter"
if !commandExists("dms-greeter") {
wrapperCmd = "/usr/local/bin/dms-greeter"
}
compositorLower := strings.ToLower(selectedCompositor)
commandLine := fmt.Sprintf(`command = "%s --command %s"`, wrapperCmd, compositorLower)
var finalLines []string
inDefaultSession := false
commandAdded := false
for _, line := range newLines {
finalLines = append(finalLines, line)
trimmed := strings.TrimSpace(line)
if trimmed == "[default_session]" {
inDefaultSession = true
}
if inDefaultSession && !commandAdded {
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
finalLines = append(finalLines, commandLine)
commandAdded = true
}
}
}
if !commandAdded {
finalLines = append(finalLines, commandLine)
}
newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err)
}
moveCmd := exec.Command("sudo", "mv", tmpFile, configPath)
if err := moveCmd.Run(); err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
fmt.Printf("✓ Updated greetd configuration to use %s\n", selectedCompositor)
fmt.Println("\n=== Enable Complete ===")
fmt.Println("\nTo start the greeter, run:")
fmt.Println(" sudo systemctl start greetd")
fmt.Println("\nTo enable on boot, run:")
fmt.Println(" sudo systemctl enable --now greetd")
return nil
}
func promptCompositorChoice(compositors []string) (string, error) {
fmt.Println("\nMultiple compositors detected:")
for i, comp := range compositors {
fmt.Printf("%d) %s\n", i+1, comp)
}
var response string
fmt.Print("Choose compositor for greeter: ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
choice := 0
fmt.Sscanf(response, "%d", &choice)
if choice < 1 || choice > len(compositors) {
return "", fmt.Errorf("invalid choice")
}
return compositors[choice-1], nil
}
func checkGreeterStatus() error {
fmt.Println("=== DMS Greeter Status ===")
fmt.Println()
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
configPath := "/etc/greetd/config.toml"
fmt.Println("Greeter Configuration:")
if data, err := os.ReadFile(configPath); err == nil {
configContent := string(data)
if strings.Contains(configContent, "dms-greeter") {
lines := strings.Split(configContent, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 {
command := strings.Trim(strings.TrimSpace(parts[1]), `"`)
fmt.Println(" ✓ Greeter is enabled")
if strings.Contains(command, "--command niri") {
fmt.Println(" Compositor: niri")
} else if strings.Contains(command, "--command hyprland") {
fmt.Println(" Compositor: Hyprland")
} else if strings.Contains(command, "--command sway") {
fmt.Println(" Compositor: sway")
} else {
fmt.Println(" Compositor: unknown")
}
}
break
}
}
} else {
fmt.Println(" ✗ Greeter is NOT enabled")
fmt.Println(" Run 'dms greeter enable' to enable it")
}
} else {
fmt.Println(" ✗ Greeter config not found")
fmt.Println(" Run 'dms greeter install' to install greeter")
}
fmt.Println("\nGroup Membership:")
groupsCmd := exec.Command("groups", currentUser.Username)
groupsOutput, err := groupsCmd.Output()
if err != nil {
return fmt.Errorf("failed to check groups: %w", err)
}
inGreeterGroup := strings.Contains(string(groupsOutput), "greeter")
if inGreeterGroup {
fmt.Println(" ✓ User is in greeter group")
} else {
fmt.Println(" ✗ User is NOT in greeter group")
fmt.Println(" Run 'dms greeter install' to add user to greeter group")
}
cacheDir := "/var/cache/dms-greeter"
fmt.Println("\nGreeter Cache Directory:")
if stat, err := os.Stat(cacheDir); err == nil && stat.IsDir() {
fmt.Printf(" ✓ %s exists\n", cacheDir)
} else {
fmt.Printf(" ✗ %s not found\n", cacheDir)
fmt.Println(" Run 'dms greeter install' to create cache directory")
return nil
}
fmt.Println("\nConfiguration Symlinks:")
symlinks := []struct {
source string
target string
desc string
}{
{
source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"),
target: filepath.Join(cacheDir, "settings.json"),
desc: "Settings",
},
{
source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"),
target: filepath.Join(cacheDir, "session.json"),
desc: "Session state",
},
{
source: filepath.Join(homeDir, ".cache", "quickshell", "dankshell", "dms-colors.json"),
target: filepath.Join(cacheDir, "colors.json"),
desc: "Color theme",
},
}
allGood := true
for _, link := range symlinks {
targetInfo, err := os.Lstat(link.target)
if err != nil {
fmt.Printf(" ✗ %s: symlink not found at %s\n", link.desc, link.target)
allGood = false
continue
}
if targetInfo.Mode()&os.ModeSymlink == 0 {
fmt.Printf(" ✗ %s: %s is not a symlink\n", link.desc, link.target)
allGood = false
continue
}
linkDest, err := os.Readlink(link.target)
if err != nil {
fmt.Printf(" ✗ %s: failed to read symlink\n", link.desc)
allGood = false
continue
}
if linkDest != link.source {
fmt.Printf(" ✗ %s: symlink points to wrong location\n", link.desc)
fmt.Printf(" Expected: %s\n", link.source)
fmt.Printf(" Got: %s\n", linkDest)
allGood = false
continue
}
if _, err := os.Stat(link.source); os.IsNotExist(err) {
fmt.Printf(" ⚠ %s: symlink OK, but source file doesn't exist yet\n", link.desc)
fmt.Printf(" Will be created when you run DMS\n")
continue
}
fmt.Printf(" ✓ %s: synced correctly\n", link.desc)
}
fmt.Println()
if allGood && inGreeterGroup {
fmt.Println("✓ All checks passed! Greeter is properly configured.")
} else if !allGood {
fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to fix symlinks.")
}
return nil
}

View File

@@ -0,0 +1,129 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/keybinds/providers"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/spf13/cobra"
)
var keybindsCmd = &cobra.Command{
Use: "keybinds",
Aliases: []string{"cheatsheet", "chsht"},
Short: "Manage keybinds and cheatsheets",
Long: "Display and manage keybinds and cheatsheets for various applications",
}
var keybindsListCmd = &cobra.Command{
Use: "list",
Short: "List available providers",
Long: "List all available keybind/cheatsheet providers",
Run: runKeybindsList,
}
var keybindsShowCmd = &cobra.Command{
Use: "show <provider>",
Short: "Show keybinds for a provider",
Long: "Display keybinds/cheatsheet for the specified provider",
Args: cobra.ExactArgs(1),
Run: runKeybindsShow,
}
func init() {
keybindsShowCmd.Flags().String("hyprland-path", "$HOME/.config/hypr", "Path to Hyprland config directory")
keybindsShowCmd.Flags().String("mangowc-path", "$HOME/.config/mango", "Path to MangoWC config directory")
keybindsShowCmd.Flags().String("sway-path", "$HOME/.config/sway", "Path to Sway config directory")
keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath)
})
initializeProviders()
}
func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("$HOME/.config/hypr")
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
mangowcProvider := providers.NewMangoWCProvider("$HOME/.config/mango")
if err := registry.Register(mangowcProvider); err != nil {
log.Warnf("Failed to register MangoWC provider: %v", err)
}
swayProvider := providers.NewSwayProvider("$HOME/.config/sway")
if err := registry.Register(swayProvider); err != nil {
log.Warnf("Failed to register Sway provider: %v", err)
}
config := keybinds.DefaultDiscoveryConfig()
if err := keybinds.AutoDiscoverProviders(registry, config); err != nil {
log.Warnf("Failed to auto-discover providers: %v", err)
}
}
func runKeybindsList(cmd *cobra.Command, args []string) {
registry := keybinds.GetDefaultRegistry()
providers := registry.List()
if len(providers) == 0 {
fmt.Fprintln(os.Stdout, "No providers available")
return
}
fmt.Fprintln(os.Stdout, "Available providers:")
for _, name := range providers {
fmt.Fprintf(os.Stdout, " - %s\n", name)
}
}
func runKeybindsShow(cmd *cobra.Command, args []string) {
providerName := args[0]
registry := keybinds.GetDefaultRegistry()
if providerName == "hyprland" {
hyprlandPath, _ := cmd.Flags().GetString("hyprland-path")
hyprlandProvider := providers.NewHyprlandProvider(hyprlandPath)
registry.Register(hyprlandProvider)
}
if providerName == "mangowc" {
mangowcPath, _ := cmd.Flags().GetString("mangowc-path")
mangowcProvider := providers.NewMangoWCProvider(mangowcPath)
registry.Register(mangowcProvider)
}
if providerName == "sway" {
swayPath, _ := cmd.Flags().GetString("sway-path")
swayProvider := providers.NewSwayProvider(swayPath)
registry.Register(swayProvider)
}
provider, err := registry.Get(providerName)
if err != nil {
log.Fatalf("Error: %v", err)
}
sheet, err := provider.GetCheatSheet()
if err != nil {
log.Fatalf("Error getting cheatsheet: %v", err)
}
output, err := json.MarshalIndent(sheet, "", " ")
if err != nil {
log.Fatalf("Error generating JSON: %v", err)
}
fmt.Fprintln(os.Stdout, string(output))
}

View File

@@ -0,0 +1,99 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/config"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/dms"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
var customConfigPath string
var configPath string
var rootCmd = &cobra.Command{
Use: "dms",
Short: "dms CLI",
Long: "dms is the DankMaterialShell management CLI and backend server.",
Run: runInteractiveMode,
}
func init() {
// Add the -c flag
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
}
func findConfig(cmd *cobra.Command, args []string) error {
if customConfigPath != "" {
log.Debug("Custom config path provided via -c flag: %s", customConfigPath)
shellPath := filepath.Join(customConfigPath, "shell.qml")
info, statErr := os.Stat(shellPath)
if statErr == nil && !info.IsDir() {
configPath = customConfigPath
log.Debug("Using config from: %s", configPath)
return nil // <-- Guard statement
}
if statErr != nil {
return fmt.Errorf("custom config path error: %w", statErr)
}
return fmt.Errorf("path is a directory, not a file: %s", shellPath)
}
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if data, readErr := os.ReadFile(configStateFile); readErr == nil {
statePath := strings.TrimSpace(string(data))
shellPath := filepath.Join(statePath, "shell.qml")
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
log.Debug("Using config from active session state file: %s", statePath)
configPath = statePath
log.Debug("Using config from: %s", configPath)
return nil // <-- Guard statement
} else {
os.Remove(configStateFile)
}
}
log.Debug("No custom path or active session, searching default XDG locations...")
var err error
configPath, err = config.LocateDMSConfig()
if err != nil {
return err
}
log.Debug("Using config from: %s", configPath)
return nil
}
func runInteractiveMode(cmd *cobra.Command, args []string) {
detector, err := dms.NewDetector()
if err != nil && !errors.Is(err, &distros.UnsupportedDistributionError{}) {
log.Fatalf("Error initializing DMS detector: %v", err)
} else if errors.Is(err, &distros.UnsupportedDistributionError{}) {
log.Error("Interactive mode is not supported on this distribution.")
log.Info("Please run 'dms --help' for available commands.")
os.Exit(1)
}
if !detector.IsDMSInstalled() {
log.Error("DankMaterialShell (DMS) is not detected as installed on this system.")
log.Info("Please install DMS using dankinstall before using this management interface.")
os.Exit(1)
}
model := dms.NewModel(Version)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("Error running program: %v", err)
}
}

View File

@@ -0,0 +1,182 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/config"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/spf13/cobra"
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Deploy DMS configurations",
Long: "Deploy compositor and terminal configurations with interactive prompts",
Run: func(cmd *cobra.Command, args []string) {
if err := runSetup(); err != nil {
log.Fatalf("Error during setup: %v", err)
}
},
}
func runSetup() error {
fmt.Println("=== DMS Configuration Setup ===")
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
if !wmSelected && !terminalSelected {
fmt.Println("No configurations selected. Exiting.")
return nil
}
if wmSelected || terminalSelected {
willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected)
if willBackup {
fmt.Println("\n⚠ Existing configurations will be backed up with timestamps.")
}
fmt.Print("\nProceed with deployment? (y/N): ")
var response string
fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
if response != "y" && response != "yes" {
fmt.Println("Setup cancelled.")
return nil
}
}
fmt.Println("\nDeploying configurations...")
logChan := make(chan string, 100)
deployer := config.NewConfigDeployer(logChan)
go func() {
for msg := range logChan {
fmt.Println(" " + msg)
}
}()
ctx := context.Background()
var results []config.DeploymentResult
var err error
if wmSelected && terminalSelected {
results, err = deployer.DeployConfigurationsWithTerminal(ctx, wm, terminal)
} else if wmSelected {
results, err = deployer.DeployConfigurationsWithTerminal(ctx, wm, deps.TerminalGhostty)
if len(results) > 1 {
results = results[:1]
}
} else if terminalSelected {
results, err = deployer.DeployConfigurationsWithTerminal(ctx, deps.WindowManagerNiri, terminal)
if len(results) > 0 && results[0].ConfigType == "Niri" {
results = results[1:]
}
}
close(logChan)
if err != nil {
return fmt.Errorf("deployment failed: %w", err)
}
fmt.Println("\n=== Deployment Complete ===")
for _, result := range results {
if result.Deployed {
fmt.Printf("✓ %s: %s\n", result.ConfigType, result.Path)
if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath)
}
}
}
return nil
}
func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")
fmt.Println("2) Hyprland")
fmt.Println("3) None")
var response string
fmt.Print("\nChoice (1-3): ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
switch response {
case "1":
return deps.WindowManagerNiri, true
case "2":
return deps.WindowManagerHyprland, true
default:
return deps.WindowManagerNiri, false
}
}
func promptTerminal() (deps.Terminal, bool) {
fmt.Println("\nSelect terminal:")
fmt.Println("1) Ghostty")
fmt.Println("2) Kitty")
fmt.Println("3) Alacritty")
fmt.Println("4) None")
var response string
fmt.Print("\nChoice (1-4): ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
switch response {
case "1":
return deps.TerminalGhostty, true
case "2":
return deps.TerminalKitty, true
case "3":
return deps.TerminalAlacritty, true
default:
return deps.TerminalGhostty, false
}
}
func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool {
homeDir := os.Getenv("HOME")
willBackup := false
if wmSelected {
var configPath string
switch wm {
case deps.WindowManagerNiri:
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
case deps.WindowManagerHyprland:
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
}
}
if terminalSelected {
var configPath string
switch terminal {
case deps.TerminalGhostty:
configPath = filepath.Join(homeDir, ".config", "ghostty", "config")
case deps.TerminalKitty:
configPath = filepath.Join(homeDir, ".config", "kitty", "kitty.conf")
case deps.TerminalAlacritty:
configPath = filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml")
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
}
}
return willBackup
}

44
backend/cmd/dms/main.go Normal file
View File

@@ -0,0 +1,44 @@
//go:build !distro_binary
package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
)
var Version = "dev"
func init() {
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("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().MarkHidden("daemon-child")
// Add subcommands to greeter
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to update
updateCmd.AddCommand(updateCheckCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(updateCmd)
rootCmd.SetHelpTemplate(getHelpTemplate())
}
func main() {
if os.Geteuid() == 0 {
log.Fatal("This program should not be run as root. Exiting.")
}
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,41 @@
//go:build distro_binary
package main
import (
"os"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
)
var Version = "dev"
func init() {
// Add flags
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("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().MarkHidden("daemon-child")
// Add subcommands to greeter
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
// Add subcommands to plugins
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd)
// Add common commands to root
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.SetHelpTemplate(getHelpTemplate())
}
func main() {
// Block root
if os.Geteuid() == 0 {
log.Fatal("This program should not be run as root. Exiting.")
}
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}

482
backend/cmd/dms/shell.go Normal file
View File

@@ -0,0 +1,482 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/server"
)
var isSessionManaged bool
func execDetachedRestart(targetPID int) {
selfPath, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(selfPath, "restart-detached", strconv.Itoa(targetPID))
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}
func runDetachedRestart(targetPIDStr string) {
targetPID, err := strconv.Atoi(targetPIDStr)
if err != nil {
return
}
time.Sleep(200 * time.Millisecond)
proc, err := os.FindProcess(targetPID)
if err == nil {
proc.Signal(syscall.SIGTERM)
}
time.Sleep(500 * time.Millisecond)
killShell()
runShellDaemon(false)
}
func getRuntimeDir() string {
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
return runtime
}
return os.TempDir()
}
func getPIDFilePath() string {
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid()))
}
func writePIDFile(childPID int) error {
pidFile := getPIDFilePath()
return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0644)
}
func removePIDFile() {
pidFile := getPIDFilePath()
os.Remove(pidFile)
}
func getAllDMSPIDs() []int {
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var pids []int
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") {
continue
}
pidFile := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(pidFile)
if err != nil {
continue
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
os.Remove(pidFile)
continue
}
// Check if the child process is still alive
proc, err := os.FindProcess(childPID)
if err != nil {
os.Remove(pidFile)
continue
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
// Process is dead, remove stale PID file
os.Remove(pidFile)
continue
}
pids = append(pids, childPID)
// Also get the parent PID from the filename
parentPIDStr := strings.TrimPrefix(entry.Name(), "danklinux-")
parentPIDStr = strings.TrimSuffix(parentPIDStr, ".pid")
if parentPID, err := strconv.Atoi(parentPIDStr); err == nil {
// Check if parent is still alive
if parentProc, err := os.FindProcess(parentPID); err == nil {
if err := parentProc.Signal(syscall.Signal(0)); err == nil {
pids = append(pids, parentPID)
}
}
}
}
return pids
}
func runShellInteractive(session bool) {
isSessionManaged = session
go printASCII()
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
socketPath := server.GetSocketPath()
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if err := os.WriteFile(configStateFile, []byte(configPath), 0644); err != nil {
log.Warnf("Failed to write config state file: %v", err)
}
defer os.Remove(configStateFile)
errChan := make(chan error, 2)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("server panic: %v", r)
}
}()
if err := server.Start(false); err != nil {
errChan <- fmt.Errorf("server error: %w", err)
}
}()
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
homeDir, err := os.UserHomeDir()
if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" {
if !strings.HasPrefix(configPath, homeDir) {
cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1")
}
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting quickshell: %v", err)
}
// Write PID file for the quickshell child process
if err := writePIDFile(cmd.Process.Pid); err != nil {
log.Warnf("Failed to write PID file: %v", err)
}
defer removePIDFile()
defer func() {
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
go func() {
if err := cmd.Wait(); err != nil {
errChan <- fmt.Errorf("quickshell exited: %w", err)
} else {
errChan <- fmt.Errorf("quickshell exited")
}
}()
for {
select {
case sig := <-sigChan:
// Handle SIGUSR1 restart for non-session managed processes
if sig == syscall.SIGUSR1 && !isSessionManaged {
log.Infof("Received SIGUSR1, spawning detached restart process...")
execDetachedRestart(os.Getpid())
// Exit immediately to avoid race conditions with detached restart
return
}
// All other signals: clean shutdown
log.Infof("\nReceived signal %v, shutting down...", sig)
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
return
case err := <-errChan:
log.Error(err)
cancel()
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
os.Remove(socketPath)
os.Exit(1)
}
}
}
func restartShell() {
pids := getAllDMSPIDs()
if len(pids) == 0 {
log.Info("No running DMS shell instances found. Starting daemon...")
runShellDaemon(false)
return
}
currentPid := os.Getpid()
uniquePids := make(map[int]bool)
for _, pid := range pids {
if pid != currentPid {
uniquePids[pid] = true
}
}
for pid := range uniquePids {
proc, err := os.FindProcess(pid)
if err != nil {
log.Errorf("Error finding process %d: %v", pid, err)
continue
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
continue
}
if err := proc.Signal(syscall.SIGUSR1); err != nil {
log.Errorf("Error sending SIGUSR1 to process %d: %v", pid, err)
} else {
log.Infof("Sent SIGUSR1 to DMS process with PID %d", pid)
}
}
}
func killShell() {
// Get all tracked DMS PIDs from PID files
pids := getAllDMSPIDs()
if len(pids) == 0 {
log.Info("No running DMS shell instances found.")
return
}
currentPid := os.Getpid()
uniquePids := make(map[int]bool)
// Deduplicate and filter out current process
for _, pid := range pids {
if pid != currentPid {
uniquePids[pid] = true
}
}
// Kill all tracked processes
for pid := range uniquePids {
proc, err := os.FindProcess(pid)
if err != nil {
log.Errorf("Error finding process %d: %v", pid, err)
continue
}
// Check if process is still alive before killing
if err := proc.Signal(syscall.Signal(0)); err != nil {
continue
}
if err := proc.Kill(); err != nil {
log.Errorf("Error killing process %d: %v", pid, err)
} else {
log.Infof("Killed DMS process with PID %d", pid)
}
}
// Clean up any remaining PID files
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".pid") {
pidFile := filepath.Join(dir, entry.Name())
os.Remove(pidFile)
}
}
}
func runShellDaemon(session bool) {
isSessionManaged = session
// Check if this is the daemon child process by looking for the hidden flag
isDaemonChild := false
for _, arg := range os.Args {
if arg == "--daemon-child" {
isDaemonChild = true
break
}
}
if !isDaemonChild {
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
cmd := exec.Command(os.Args[0], "run", "-d", "--daemon-child")
cmd.Env = os.Environ()
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err)
}
log.Infof("DMS shell daemon started (PID: %d)", cmd.Process.Pid)
return
}
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
socketPath := server.GetSocketPath()
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if err := os.WriteFile(configStateFile, []byte(configPath), 0644); err != nil {
log.Warnf("Failed to write config state file: %v", err)
}
defer os.Remove(configStateFile)
errChan := make(chan error, 2)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("server panic: %v", r)
}
}()
if err := server.Start(false); err != nil {
errChan <- fmt.Errorf("server error: %w", err)
}
}()
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
homeDir, err := os.UserHomeDir()
if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" {
if !strings.HasPrefix(configPath, homeDir) {
cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1")
}
}
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil {
log.Fatalf("Error opening /dev/null: %v", err)
}
defer devNull.Close()
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err)
}
// Write PID file for the quickshell child process
if err := writePIDFile(cmd.Process.Pid); err != nil {
log.Warnf("Failed to write PID file: %v", err)
}
defer removePIDFile()
defer func() {
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
go func() {
if err := cmd.Wait(); err != nil {
errChan <- fmt.Errorf("quickshell exited: %w", err)
} else {
errChan <- fmt.Errorf("quickshell exited")
}
}()
for {
select {
case sig := <-sigChan:
// Handle SIGUSR1 restart for non-session managed processes
if sig == syscall.SIGUSR1 && !isSessionManaged {
log.Infof("Received SIGUSR1, spawning detached restart process...")
execDetachedRestart(os.Getpid())
// Exit immediately to avoid race conditions with detached restart
return
}
// All other signals: clean shutdown
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
return
case <-errChan:
cancel()
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
os.Remove(socketPath)
os.Exit(1)
}
}
}
func runShellIPCCommand(args []string) {
if len(args) == 0 {
log.Error("IPC command requires arguments")
log.Info("Usage: dms ipc <command> [args...]")
os.Exit(1)
}
if args[0] != "call" {
args = append([]string{"call"}, args...)
}
cmdArgs := append([]string{"-p", configPath, "ipc"}, args...)
cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("Error running IPC command: %v", err)
}
}

53
backend/cmd/dms/ui.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/tui"
"github.com/charmbracelet/lipgloss"
)
func printASCII() {
fmt.Print(getThemedASCII())
}
func getThemedASCII() string {
theme := tui.TerminalTheme()
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
style := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true)
return style.Render(logo) + "\n"
}
func getHelpTemplate() string {
return getThemedASCII() + `
{{.Long}}
Usage:
{{.UseLine}}{{if .HasAvailableSubCommands}}
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`
}

14
backend/cmd/dms/utils.go Normal file
View File

@@ -0,0 +1,14 @@
package main
import "os/exec"
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func isArchPackageInstalled(packageName string) bool {
cmd := exec.Command("pacman", "-Q", packageName)
err := cmd.Run()
return err == nil
}

27
backend/flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1760878510,
"narHash": "sha256-K5Osef2qexezUfs0alLvZ7nQFTGS9DL2oTVsIXsqLgs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5e2a59a5b1a82f89f2c7e598302a9cacebb72a67",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

61
backend/flake.nix Normal file
View File

@@ -0,0 +1,61 @@
{
description = "DankMaterialShell Command Line Interface";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems =
f:
builtins.listToAttrs (
map (system: {
name = system;
value = f system;
}) supportedSystems
);
in
{
packages = forAllSystems (
system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
in
{
dms-cli = pkgs.buildGoModule (finalAttrs: {
pname = "dms-cli";
version = "0.4.3";
src = ./.;
vendorHash = "sha256-XbCg6qQwD4g4R/hBReLGE4NOq9uv0LBqogmfpBs//Ic=";
subPackages = [ "cmd/dms" ];
ldflags = [
"-s"
"-w"
"-X main.Version=${finalAttrs.version}"
];
meta = {
description = "DankMaterialShell Command Line Interface";
homepage = "https://github.com/AvengeMedia/DankMaterialShell/backend";
mainProgram = "dms";
license = lib.licenses.mit;
platforms = lib.platforms.unix;
};
});
default = self.packages.${system}.dms-cli;
}
);
};
}

65
backend/go.mod Normal file
View File

@@ -0,0 +1,65 @@
module github.com/AvengeMedia/DankMaterialShell/backend
go 1.24.6
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/godbus/dbus/v5 v5.1.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.44.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20250929195514-145daf2492dd
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0
golang.org/x/text v0.29.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

141
backend/go.sum Normal file
View File

@@ -0,0 +1,141 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
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/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
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/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
github.com/go-git/go-git/v6 v6.0.0-20250929195514-145daf2492dd h1:30HEd5KKVM7GgMJ1GSNuYxuZXEg8Pdlngp6T51faxoc=
github.com/go-git/go-git/v6 v6.0.0-20250929195514-145daf2492dd/go.mod h1:lz8PQr/p79XpFq5ODVBwRJu5LnOF8Et7j95ehqmCMJU=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
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/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
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/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34 h1:iTAt1me6SBYsuzrl/CmrxtATPlOG/pVviosM3DhUdKE=
github.com/yaslama/go-wayland/wayland v0.0.0-20250907155644-2874f32d9c34/go.mod h1:jzmUN5lUAv2O8e63OvcauV4S30rIZ1BvF/PNYE37vDo=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

86
backend/install.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/sh
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Check for root privileges
if [ "$(id -u)" == "0" ]; then
printf "%bError: This script must not be run as root%b\n" "$RED" "$NC"
exit 1
fi
# Check if running on Linux
if [ "$(uname)" != "Linux" ]; then
printf "%bError: This installer only supports Linux systems%b\n" "$RED" "$NC"
exit 1
fi
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH="amd64"
;;
aarch64)
ARCH="arm64"
;;
*)
printf "%bError: Unsupported architecture: %s%b\n" "$RED" "$ARCH" "$NC"
printf "This installer only supports x86_64 (amd64) and aarch64 (arm64) architectures\n"
exit 1
;;
esac
# Get the latest release version
LATEST_VERSION=$(curl -s https://api.github.com/repos/AvengeMedia/danklinux/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC"
exit 1
fi
printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
# Download and install
TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR" || exit 1
# Download the gzipped binary and its checksum
printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
curl -L "https://github.com/AvengeMedia/DankMaterialShell/backend/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz" -o "installer.gz"
curl -L "https://github.com/AvengeMedia/DankMaterialShell/backend/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256"
# Get the expected checksum
EXPECTED_CHECKSUM=$(cat expected.sha256 | awk '{print $1}')
# Calculate actual checksum
printf "%bVerifying checksum...%b\n" "$GREEN" "$NC"
ACTUAL_CHECKSUM=$(sha256sum installer.gz | awk '{print $1}')
# Compare checksums
if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
printf "%bError: Checksum verification failed%b\n" "$RED" "$NC"
printf "Expected: %s\n" "$EXPECTED_CHECKSUM"
printf "Got: %s\n" "$ACTUAL_CHECKSUM"
printf "The downloaded file may be corrupted or tampered with\n"
cd - > /dev/null
rm -rf "$TEMP_DIR"
exit 1
fi
# Decompress the binary
printf "%bDecompressing installer...%b\n" "$GREEN" "$NC"
gunzip installer.gz
chmod +x installer
# Execute the installer
printf "%bRunning installer...%b\n" "$GREEN" "$NC"
./installer
# Cleanup
cd - > /dev/null
rm -rf "$TEMP_DIR"

View File

@@ -0,0 +1,574 @@
package config
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
type ConfigDeployer struct {
logChan chan<- string
}
type DeploymentResult struct {
ConfigType string
Path string
BackupPath string
Deployed bool
Error error
}
func NewConfigDeployer(logChan chan<- string) *ConfigDeployer {
return &ConfigDeployer{
logChan: logChan,
}
}
func (cd *ConfigDeployer) log(message string) {
if cd.logChan != nil {
cd.logChan <- message
}
}
// DeployConfigurations deploys all necessary configurations based on the chosen window manager
func (cd *ConfigDeployer) DeployConfigurations(ctx context.Context, wm deps.WindowManager) ([]DeploymentResult, error) {
return cd.DeployConfigurationsWithTerminal(ctx, wm, deps.TerminalGhostty)
}
// DeployConfigurationsWithTerminal deploys all necessary configurations based on chosen window manager and terminal
func (cd *ConfigDeployer) DeployConfigurationsWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelective(ctx, wm, terminal, nil, nil)
}
func (cd *ConfigDeployer) DeployConfigurationsSelective(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelectiveWithReinstalls(ctx, wm, terminal, installedDeps, replaceConfigs, nil)
}
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool) ([]DeploymentResult, error) {
var results []DeploymentResult
shouldReplaceConfig := func(configType string) bool {
if replaceConfigs == nil {
return true
}
replace, exists := replaceConfigs[configType]
return !exists || replace
}
switch wm {
case deps.WindowManagerNiri:
if shouldReplaceConfig("Niri") {
result, err := cd.deployNiriConfig(terminal)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Niri config: %w", err)
}
}
case deps.WindowManagerHyprland:
if shouldReplaceConfig("Hyprland") {
result, err := cd.deployHyprlandConfig(terminal)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
}
}
}
switch terminal {
case deps.TerminalGhostty:
if shouldReplaceConfig("Ghostty") {
ghosttyResults, err := cd.deployGhosttyConfig()
results = append(results, ghosttyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Ghostty config: %w", err)
}
}
case deps.TerminalKitty:
if shouldReplaceConfig("Kitty") {
kittyResults, err := cd.deployKittyConfig()
results = append(results, kittyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Kitty config: %w", err)
}
}
case deps.TerminalAlacritty:
if shouldReplaceConfig("Alacritty") {
alacrittyResults, err := cd.deployAlacrittyConfig()
results = append(results, alacrittyResults...)
if err != nil {
return results, fmt.Errorf("failed to deploy Alacritty config: %w", err)
}
}
}
return results, nil
}
// deployNiriConfig handles Niri configuration deployment with backup and merging
func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Niri",
Path: filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Niri configuration")
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
// Detect polkit agent path
polkitPath, err := cd.detectPolkitAgent()
if err != nil {
cd.log(fmt.Sprintf("Warning: Could not detect polkit agent: %v", err))
polkitPath = "/usr/lib/mate-polkit/polkit-mate-authentication-agent-1" // fallback
}
// Determine terminal command based on choice
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty" // fallback to ghostty
}
newConfig := strings.ReplaceAll(NiriConfig, "{{POLKIT_AGENT_PATH}}", polkitPath)
newConfig = strings.ReplaceAll(newConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
// If there was an existing config, merge the output sections
if existingConfig != "" {
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err))
} else {
newConfig = mergedConfig
cd.log("Successfully merged existing output sections")
}
}
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Niri configuration")
return result, nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Ghostty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Ghostty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Ghostty configuration")
results = append(results, mainResult)
colorResult := DeploymentResult{
ConfigType: "Ghostty Colors",
Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config-dankcolors"),
}
if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0644); err != nil {
colorResult.Error = fmt.Errorf("failed to write color config: %w", err)
return results, colorResult.Error
}
colorResult.Deployed = true
cd.log("Successfully deployed Ghostty color configuration")
results = append(results, colorResult)
return results, nil
}
func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Kitty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Kitty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Kitty configuration")
results = append(results, mainResult)
themeResult := DeploymentResult{
ConfigType: "Kitty Theme",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"),
}
if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error
}
themeResult.Deployed = true
cd.log("Successfully deployed Kitty theme configuration")
results = append(results, themeResult)
tabsResult := DeploymentResult{
ConfigType: "Kitty Tabs",
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"),
}
if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0644); err != nil {
tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err)
return results, tabsResult.Error
}
tabsResult.Deployed = true
cd.log("Successfully deployed Kitty tabs configuration")
results = append(results, tabsResult)
return results, nil
}
func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
mainResult := DeploymentResult{
ConfigType: "Alacritty",
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
}
configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
if _, err := os.Stat(mainResult.Path); err == nil {
cd.log("Found existing Alacritty configuration")
existingData, err := os.ReadFile(mainResult.Path)
if err != nil {
mainResult.Error = fmt.Errorf("failed to read existing config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
mainResult.Deployed = true
cd.log("Successfully deployed Alacritty configuration")
results = append(results, mainResult)
themeResult := DeploymentResult{
ConfigType: "Alacritty Theme",
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"),
}
if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error
}
themeResult.Deployed = true
cd.log("Successfully deployed Alacritty theme configuration")
results = append(results, themeResult)
return results, nil
}
// detectPolkitAgent tries to find the polkit authentication agent on the system
// Prioritizes mate-polkit paths since that's what we install
func (cd *ConfigDeployer) detectPolkitAgent() (string, error) {
// Prioritize mate-polkit paths first
matePaths := []string{
"/usr/libexec/polkit-mate-authentication-agent-1", // Fedora path
"/usr/lib/mate-polkit/polkit-mate-authentication-agent-1",
"/usr/libexec/mate-polkit/polkit-mate-authentication-agent-1",
"/usr/lib/polkit-mate/polkit-mate-authentication-agent-1",
"/usr/lib/x86_64-linux-gnu/mate-polkit/polkit-mate-authentication-agent-1",
}
for _, path := range matePaths {
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Found mate-polkit agent at: %s", path))
return path, nil
}
}
// Fallback to other polkit agents if mate-polkit is not found
fallbackPaths := []string{
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
"/usr/libexec/polkit-gnome-authentication-agent-1",
}
for _, path := range fallbackPaths {
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Found fallback polkit agent at: %s", path))
return path, nil
}
}
return "", fmt.Errorf("no polkit agent found in common locations")
}
// mergeNiriOutputSections extracts output sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match output sections (including commented ones)
outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
// Find all output sections in the existing config
existingOutputs := outputRegex.FindAllString(existingConfig, -1)
if len(existingOutputs) == 0 {
// No output sections to merge
return newConfig, nil
}
// Remove the example output section from the new config
exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "")
// Find where to insert the output sections (after the input section)
inputEndRegex := regexp.MustCompile(`(?m)^}$`)
inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1)
if len(inputMatches) < 1 {
return "", fmt.Errorf("could not find insertion point for output sections")
}
// Insert after the first closing brace (end of input section)
insertPos := inputMatches[0][1]
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("\n// Outputs from existing configuration\n")
for _, output := range existingOutputs {
builder.WriteString(output)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}
// deployHyprlandConfig handles Hyprland configuration deployment with backup and merging
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Hyprland",
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration")
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
// Detect polkit agent path
polkitPath, err := cd.detectPolkitAgent()
if err != nil {
cd.log(fmt.Sprintf("Warning: Could not detect polkit agent: %v", err))
polkitPath = "/usr/lib/mate-polkit/polkit-mate-authentication-agent-1" // fallback
}
// Determine terminal command based on choice
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty" // fallback to ghostty
}
newConfig := strings.ReplaceAll(HyprlandConfig, "{{POLKIT_AGENT_PATH}}", polkitPath)
newConfig = strings.ReplaceAll(newConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
// If there was an existing config, merge the monitor sections
if existingConfig != "" {
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
} else {
newConfig = mergedConfig
cd.log("Successfully merged existing monitor sections")
}
}
if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Hyprland configuration")
return result, nil
}
// mergeHyprlandMonitorSections extracts monitor sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match monitor lines (including commented ones)
// Matches: monitor = NAME, RESOLUTION, POSITION, SCALE, etc.
// Also matches commented versions: # monitor = ...
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
// Find all monitor lines in the existing config
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
if len(existingMonitors) == 0 {
// No monitor sections to merge
return newConfig, nil
}
// Remove the example monitor line from the new config
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
// Find where to insert the monitor sections (after the MONITOR CONFIG header)
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
if headerMatch == nil {
return "", fmt.Errorf("could not find MONITOR CONFIG section")
}
// Insert after the header
insertPos := headerMatch[1] + 1 // +1 for the newline
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("# Monitors from existing configuration\n")
for _, monitor := range existingMonitors {
builder.WriteString(monitor)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}

View File

@@ -0,0 +1,660 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectPolkitAgent(t *testing.T) {
cd := &ConfigDeployer{}
// This test depends on the system having a polkit agent installed
// We'll just test that the function doesn't crash and returns some path or error
path, err := cd.detectPolkitAgent()
if err != nil {
// If no polkit agent is found, that's okay for testing
assert.Contains(t, err.Error(), "no polkit agent found")
} else {
// If found, it should be a valid path
assert.NotEmpty(t, path)
assert.True(t, strings.Contains(path, "polkit"))
}
}
func TestMergeNiriOutputSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
}{
{
name: "no existing outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{"gaps 5"}, // Should keep new config
},
{
name: "merge single output",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`output "eDP-1"`, // Existing output merged
"1920x1080@60.000000", // Existing output details
"Outputs from existing configuration", // Comment added
},
},
{
name: "merge multiple outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
/-output "HDMI-1" {
mode "1920x1080@60.000000"
position x=1920 y=0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`output "eDP-1"`, // First existing output
`/-output "HDMI-1"`, // Second existing output (commented)
"1920x1080@60.000000", // Output details
},
},
{
name: "merge commented outputs",
newConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
}
layout {
gaps 5
}`,
existingConfig: `input {
keyboard {
xkb {
}
}
}
/-output "eDP-1" {
mode "1920x1080@60.000000"
position x=0 y=0
scale 1.0
}
layout {
gaps 10
}`,
wantError: false,
wantContains: []string{
"gaps 5", // New config preserved
`/-output "eDP-1"`, // Commented output preserved
"1920x1080@60.000000", // Output details
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
assert.NotContains(t, result, `/-output "eDP-2"`, "example output should be removed")
})
}
}
func TestConfigDeploymentFlow(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-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)
t.Run("deploy ghostty config to empty directory", func(t *testing.T) {
results, err := cd.deployGhosttyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Ghostty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.Empty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "window-decoration = false")
colorResult := results[1]
assert.Equal(t, "Ghostty Colors", colorResult.ConfigType)
assert.True(t, colorResult.Deployed)
assert.FileExists(t, colorResult.Path)
colorContent, err := os.ReadFile(colorResult.Path)
require.NoError(t, err)
assert.Contains(t, string(colorContent), "background = #101418")
})
t.Run("deploy ghostty config with existing file", func(t *testing.T) {
existingContent := "# Old config\nfont-size = 14\n"
ghosttyPath := getGhosttyPath()
err := os.MkdirAll(filepath.Dir(ghosttyPath), 0755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployGhosttyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Ghostty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.NotEmpty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.Path)
assert.FileExists(t, mainResult.BackupPath)
backupContent, err := os.ReadFile(mainResult.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.NotContains(t, string(newContent), "# Old config")
colorResult := results[1]
assert.Equal(t, "Ghostty Colors", colorResult.ConfigType)
assert.True(t, colorResult.Deployed)
assert.FileExists(t, colorResult.Path)
})
}
func getGhosttyPath() string {
return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config")
}
func TestPolkitPathInjection(t *testing.T) {
testConfig := `spawn-at-startup "{{POLKIT_AGENT_PATH}}"
other content`
result := strings.Replace(testConfig, "{{POLKIT_AGENT_PATH}}", "/test/polkit/path", 1)
assert.Contains(t, result, `spawn-at-startup "/test/polkit/path"`)
assert.NotContains(t, result, "{{POLKIT_AGENT_PATH}}")
}
func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
wantNotContains []string
}{
{
name: "no existing monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================
env = XDG_CURRENT_DESKTOP,niri`,
existingConfig: `# Some other config
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
},
{
name: "merge single monitor",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `# My config
monitor = DP-1, 1920x1080@144, 0x0, 1
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{
"MONITOR CONFIG",
"monitor = DP-1, 1920x1080@144, 0x0, 1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "merge multiple monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`,
wantError: false,
wantContains: []string{
"monitor = DP-1",
"# monitor = HDMI-A-1", // Commented monitor preserved
"monitor = eDP-1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "preserve commented monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================`,
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`,
wantError: false,
wantContains: []string{
"# monitor = DP-1",
"# monitor = HDMI-A-1",
"Monitors from existing configuration",
},
},
{
name: "no monitor config section",
newConfig: `# Some config without monitor section
input {
kb_layout = us
}`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
for _, notWant := range tt.wantNotContains {
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
}
})
}
}
func TestHyprlandConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-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)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty)
require.NoError(t, err)
assert.Equal(t, "Hyprland", result.ConfigType)
assert.True(t, result.Deployed)
assert.Empty(t, result.BackupPath)
assert.FileExists(t, result.Path)
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty")
assert.Contains(t, string(content), "exec-once = ")
})
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
existingContent := `# My existing Hyprland config
monitor = DP-1, 1920x1080@144, 0x0, 1
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
general {
gaps_in = 10
}
`
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0755)
require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0644)
require.NoError(t, err)
result, err := cd.deployHyprlandConfig(deps.TerminalKitty)
require.NoError(t, err)
assert.Equal(t, "Hyprland", result.ConfigType)
assert.True(t, result.Deployed)
assert.NotEmpty(t, result.BackupPath)
assert.FileExists(t, result.Path)
assert.FileExists(t, result.BackupPath)
backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty")
assert.NotContains(t, string(newContent), "monitor = eDP-2")
})
}
func TestNiriConfigStructure(t *testing.T) {
assert.Contains(t, NiriConfig, "input {")
assert.Contains(t, NiriConfig, "layout {")
assert.Contains(t, NiriConfig, "binds {")
assert.Contains(t, NiriConfig, "{{POLKIT_AGENT_PATH}}")
assert.Contains(t, NiriConfig, `spawn "{{TERMINAL_COMMAND}}"`)
}
func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# ENVIRONMENT VARS")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
assert.Contains(t, HyprlandConfig, "{{POLKIT_AGENT_PATH}}")
assert.Contains(t, HyprlandConfig, "{{TERMINAL_COMMAND}}")
assert.Contains(t, HyprlandConfig, "exec-once = dms run")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec,")
assert.Contains(t, HyprlandConfig, "bind = $mod, space, exec, dms ipc call spotlight toggle")
assert.Contains(t, HyprlandConfig, "windowrulev2 = noborder, class:^(com\\.mitchellh\\.ghostty)$")
}
func TestGhosttyConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyConfig, "window-decoration = false")
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
assert.Contains(t, GhosttyConfig, "config-file = ./config-dankcolors")
}
func TestGhosttyColorConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyColorConfig, "background = #101418")
assert.Contains(t, GhosttyColorConfig, "foreground = #e0e2e8")
assert.Contains(t, GhosttyColorConfig, "cursor-color = #9dcbfb")
assert.Contains(t, GhosttyColorConfig, "palette = 0=#101418")
assert.Contains(t, GhosttyColorConfig, "palette = 15=#ffffff")
}
func TestKittyConfigStructure(t *testing.T) {
assert.Contains(t, KittyConfig, "font_size 12.0")
assert.Contains(t, KittyConfig, "window_padding_width 12")
assert.Contains(t, KittyConfig, "background_opacity 1.0")
assert.Contains(t, KittyConfig, "include dank-tabs.conf")
assert.Contains(t, KittyConfig, "include dank-theme.conf")
}
func TestKittyThemeConfigStructure(t *testing.T) {
assert.Contains(t, KittyThemeConfig, "foreground #e0e2e8")
assert.Contains(t, KittyThemeConfig, "background #101418")
assert.Contains(t, KittyThemeConfig, "cursor #e0e2e8")
assert.Contains(t, KittyThemeConfig, "color0 #101418")
assert.Contains(t, KittyThemeConfig, "color15 #ffffff")
}
func TestKittyTabsConfigStructure(t *testing.T) {
assert.Contains(t, KittyTabsConfig, "tab_bar_style powerline")
assert.Contains(t, KittyTabsConfig, "tab_powerline_style slanted")
assert.Contains(t, KittyTabsConfig, "active_tab_background #124a73")
assert.Contains(t, KittyTabsConfig, "inactive_tab_background #101418")
}
func TestAlacrittyConfigStructure(t *testing.T) {
assert.Contains(t, AlacrittyConfig, "[general]")
assert.Contains(t, AlacrittyConfig, "~/.config/alacritty/dank-theme.toml")
assert.Contains(t, AlacrittyConfig, "[window]")
assert.Contains(t, AlacrittyConfig, "decorations = \"None\"")
assert.Contains(t, AlacrittyConfig, "padding = { x = 12, y = 12 }")
assert.Contains(t, AlacrittyConfig, "[cursor]")
assert.Contains(t, AlacrittyConfig, "[keyboard]")
}
func TestAlacrittyThemeConfigStructure(t *testing.T) {
assert.Contains(t, AlacrittyThemeConfig, "[colors.primary]")
assert.Contains(t, AlacrittyThemeConfig, "background = '#101418'")
assert.Contains(t, AlacrittyThemeConfig, "foreground = '#e0e2e8'")
assert.Contains(t, AlacrittyThemeConfig, "[colors.cursor]")
assert.Contains(t, AlacrittyThemeConfig, "cursor = '#9dcbfb'")
assert.Contains(t, AlacrittyThemeConfig, "[colors.normal]")
assert.Contains(t, AlacrittyThemeConfig, "[colors.bright]")
}
func TestKittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-kitty-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)
t.Run("deploy kitty config to empty directory", func(t *testing.T) {
results, err := cd.deployKittyConfig()
require.NoError(t, err)
require.Len(t, results, 3)
mainResult := results[0]
assert.Equal(t, "Kitty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "include dank-theme.conf")
themeResult := results[1]
assert.Equal(t, "Kitty Theme", themeResult.ConfigType)
assert.True(t, themeResult.Deployed)
assert.FileExists(t, themeResult.Path)
tabsResult := results[2]
assert.Equal(t, "Kitty Tabs", tabsResult.ConfigType)
assert.True(t, tabsResult.Deployed)
assert.FileExists(t, tabsResult.Path)
})
}
func TestAlacrittyConfigDeployment(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-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)
t.Run("deploy alacritty config to empty directory", func(t *testing.T) {
results, err := cd.deployAlacrittyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.Equal(t, "Alacritty", mainResult.ConfigType)
assert.True(t, mainResult.Deployed)
assert.FileExists(t, mainResult.Path)
content, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "~/.config/alacritty/dank-theme.toml")
assert.Contains(t, string(content), "[window]")
themeResult := results[1]
assert.Equal(t, "Alacritty Theme", themeResult.ConfigType)
assert.True(t, themeResult.Deployed)
assert.FileExists(t, themeResult.Path)
themeContent, err := os.ReadFile(themeResult.Path)
require.NoError(t, err)
assert.Contains(t, string(themeContent), "[colors.primary]")
assert.Contains(t, string(themeContent), "background = '#101418'")
})
t.Run("deploy alacritty config with existing file", func(t *testing.T) {
existingContent := "# Old alacritty config\n[window]\nopacity = 0.9\n"
alacrittyPath := filepath.Join(tempDir, ".config", "alacritty", "alacritty.toml")
err := os.MkdirAll(filepath.Dir(alacrittyPath), 0755)
require.NoError(t, err)
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployAlacrittyConfig()
require.NoError(t, err)
require.Len(t, results, 2)
mainResult := results[0]
assert.True(t, mainResult.Deployed)
assert.NotEmpty(t, mainResult.BackupPath)
assert.FileExists(t, mainResult.BackupPath)
backupContent, err := os.ReadFile(mainResult.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
newContent, err := os.ReadFile(mainResult.Path)
require.NoError(t, err)
assert.NotContains(t, string(newContent), "# Old alacritty config")
assert.Contains(t, string(newContent), "decorations = \"None\"")
})
}

View File

@@ -0,0 +1,46 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// LocateDMSConfig searches for DMS installation following XDG Base Directory specification
func LocateDMSConfig() (string, error) {
var searchPaths []string
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
if homeDir, err := os.UserHomeDir(); err == nil {
configHome = filepath.Join(homeDir, ".config")
}
}
if configHome != "" {
searchPaths = append(searchPaths, filepath.Join(configHome, "quickshell", "dms"))
}
searchPaths = append(searchPaths, "/usr/share/quickshell/dms")
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs == "" {
configDirs = "/etc/xdg"
}
for _, dir := range strings.Split(configDirs, ":") {
if dir != "" {
searchPaths = append(searchPaths, filepath.Join(dir, "quickshell", "dms"))
}
}
for _, path := range searchPaths {
shellPath := filepath.Join(path, "shell.qml")
if info, err := os.Stat(shellPath); err == nil && !info.IsDir() {
return path, nil
}
}
return "", fmt.Errorf("could not find DMS config (shell.qml) in any valid config path")
}

View File

@@ -0,0 +1,31 @@
[colors.primary]
background = '#101418'
foreground = '#e0e2e8'
[colors.selection]
text = '#e0e2e8'
background = '#124a73'
[colors.cursor]
text = '#101418'
cursor = '#9dcbfb'
[colors.normal]
black = '#101418'
red = '#d75a59'
green = '#8ed88c'
yellow = '#e0d99d'
blue = '#4087bc'
magenta = '#839fbc'
cyan = '#9dcbfb'
white = '#abb2bf'
[colors.bright]
black = '#5c6370'
red = '#e57e7e'
green = '#a2e5a0'
yellow = '#efe9b3'
blue = '#a7d9ff'
magenta = '#3d8197'
cyan = '#5c7ba3'
white = '#ffffff'

View File

@@ -0,0 +1,37 @@
[general]
import = [
"~/.config/alacritty/dank-theme.toml"
]
[window]
decorations = "None"
padding = { x = 12, y = 12 }
opacity = 1.0
[scrolling]
history = 3023
[cursor]
style = { shape = "Block", blinking = "On" }
blink_interval = 500
unfocused_hollow = true
[mouse]
hide_when_typing = true
[selection]
save_to_clipboard = false
[bell]
duration = 0
[keyboard]
bindings = [
{ key = "C", mods = "Control|Shift", action = "Copy" },
{ key = "V", mods = "Control|Shift", action = "Paste" },
{ key = "N", mods = "Control|Shift", action = "SpawnNewInstance" },
{ key = "Equals", mods = "Control|Shift", action = "IncreaseFontSize" },
{ key = "Minus", mods = "Control", action = "DecreaseFontSize" },
{ key = "Key0", mods = "Control", action = "ResetFontSize" },
{ key = "Enter", mods = "Shift", chars = "\n" },
]

View File

@@ -0,0 +1,21 @@
background = #101418
foreground = #e0e2e8
cursor-color = #9dcbfb
selection-background = #124a73
selection-foreground = #e0e2e8
palette = 0=#101418
palette = 1=#d75a59
palette = 2=#8ed88c
palette = 3=#e0d99d
palette = 4=#4087bc
palette = 5=#839fbc
palette = 6=#9dcbfb
palette = 7=#abb2bf
palette = 8=#5c6370
palette = 9=#e57e7e
palette = 10=#a2e5a0
palette = 11=#efe9b3
palette = 12=#a7d9ff
palette = 13=#3d8197
palette = 14=#5c7ba3
palette = 15=#ffffff

View File

@@ -0,0 +1,51 @@
# Font Configuration
font-size = 12
# Window Configuration
window-decoration = false
window-padding-x = 12
window-padding-y = 12
background-opacity = 1.0
background-blur-radius = 32
# Cursor Configuration
cursor-style = block
cursor-style-blink = true
# Scrollback
scrollback-limit = 3023
# Terminal features
mouse-hide-while-typing = true
copy-on-select = false
confirm-close-surface = false
# Disable annoying copied to clipboard
app-notifications = no-clipboard-copy,no-config-reload
# Key bindings for common actions
#keybind = ctrl+c=copy_to_clipboard
#keybind = ctrl+v=paste_from_clipboard
keybind = ctrl+shift+n=new_window
keybind = ctrl+t=new_tab
keybind = ctrl+plus=increase_font_size:1
keybind = ctrl+minus=decrease_font_size:1
keybind = ctrl+zero=reset_font_size
# Material 3 UI elements
unfocused-split-opacity = 0.7
unfocused-split-fill = #44464f
# Tab configuration
gtk-titlebar = false
# Shell integration
shell-integration = detect
shell-integration-features = cursor,sudo,title,no-cursor
keybind = shift+enter=text:\n
# Rando stuff
gtk-single-instance = true
# Dank color generation
config-file = ./config-dankcolors

View File

@@ -0,0 +1,290 @@
# Hyprland Configuration
# https://wiki.hypr.land/Configuring/
# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# ENVIRONMENT VARS
# ==================
env = QT_QPA_PLATFORM,wayland
env = ELECTRON_OZONE_PLATFORM_HINT,auto
env = QT_QPA_PLATFORMTHEME,gtk3
env = QT_QPA_PLATFORMTHEME_QT6,gtk3
env = TERMINAL,{{TERMINAL_COMMAND}}
# ==================
# STARTUP APPS
# ==================
exec-once = bash -c "wl-paste --watch cliphist store &"
exec-once = dms run
exec-once = {{POLKIT_AGENT_PATH}}
# ==================
# INPUT CONFIG
# ==================
input {
kb_layout = us
numlock_by_default = true
}
# ==================
# GENERAL LAYOUT
# ==================
general {
gaps_in = 5
gaps_out = 5
border_size = 0 # off in niri
col.active_border = rgba(707070ff)
col.inactive_border = rgba(d0d0d0ff)
layout = dwindle
}
# ==================
# DECORATION
# ==================
decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 0.9
shadow {
enabled = true
range = 30
render_power = 5
offset = 0 5
color = rgba(00000070)
}
}
# ==================
# ANIMATIONS
# ==================
animations {
enabled = true
animation = windowsIn, 1, 3, default
animation = windowsOut, 1, 3, default
animation = workspaces, 1, 5, default
animation = windowsMove, 1, 4, default
animation = fade, 1, 3, default
animation = border, 1, 3, default
}
# ==================
# LAYOUTS
# ==================
dwindle {
preserve_split = true
}
master {
mfact = 0.5
}
# ==================
# MISC
# ==================
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
vrr = 1
}
# ==================
# WINDOW RULES
# ==================
windowrulev2 = tile, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = rounding 12, class:^(org\.gnome\.)
windowrulev2 = noborder, class:^(org\.gnome\.)
windowrulev2 = tile, class:^(gnome-control-center)$
windowrulev2 = tile, class:^(pavucontrol)$
windowrulev2 = tile, class:^(nm-connection-editor)$
windowrulev2 = float, class:^(gnome-calculator)$
windowrulev2 = float, class:^(galculator)$
windowrulev2 = float, class:^(blueman-manager)$
windowrulev2 = float, class:^(org\.gnome\.Nautilus)$
windowrulev2 = float, class:^(steam)$
windowrulev2 = float, class:^(xdg-desktop-portal)$
windowrulev2 = noborder, class:^(org\.wezfurlong\.wezterm)$
windowrulev2 = noborder, class:^(Alacritty)$
windowrulev2 = noborder, class:^(zen)$
windowrulev2 = noborder, class:^(com\.mitchellh\.ghostty)$
windowrulev2 = noborder, class:^(kitty)$
windowrulev2 = float, class:^(firefox)$, title:^(Picture-in-Picture)$
windowrulev2 = float, class:^(zoom)$
windowrulev2 = opacity 0.9 0.9, floating:0, focus:0
layerrule = noanim, ^(quickshell)$
# ==================
# KEYBINDINGS
# ==================
$mod = SUPER
# === Application Launchers ===
bind = $mod, T, exec, {{TERMINAL_COMMAND}}
bind = $mod, space, exec, dms ipc call spotlight toggle
bind = $mod, V, exec, dms ipc call clipboard toggle
bind = $mod, M, exec, dms ipc call processlist toggle
bind = $mod, comma, exec, dms ipc call settings toggle
bind = $mod, N, exec, dms ipc call notifications toggle
bind = $mod SHIFT, N, exec, dms ipc call notepad toggle
bind = $mod, Y, exec, dms ipc call dankdash wallpaper
bind = $mod, TAB, exec, dms ipc call hypr toggleOverview
# === Cheat sheet
bind = $mod SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = $mod ALT, L, exec, dms ipc call lock lock
bind = $mod SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist toggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = $mod, Q, killactive
bind = $mod, F, fullscreen, 1
bind = $mod SHIFT, F, fullscreen, 0
bind = $mod SHIFT, T, togglefloating
bind = $mod, W, togglegroup
# === Focus Navigation ===
bind = $mod, left, movefocus, l
bind = $mod, down, movefocus, d
bind = $mod, up, movefocus, u
bind = $mod, right, movefocus, r
bind = $mod, H, movefocus, l
bind = $mod, J, movefocus, d
bind = $mod, K, movefocus, u
bind = $mod, L, movefocus, r
# === Window Movement ===
bind = $mod SHIFT, left, movewindow, l
bind = $mod SHIFT, down, movewindow, d
bind = $mod SHIFT, up, movewindow, u
bind = $mod SHIFT, right, movewindow, r
bind = $mod SHIFT, H, movewindow, l
bind = $mod SHIFT, J, movewindow, d
bind = $mod SHIFT, K, movewindow, u
bind = $mod SHIFT, L, movewindow, r
# === Column Navigation ===
bind = $mod, Home, focuswindow, first
bind = $mod, End, focuswindow, last
# === Monitor Navigation ===
bind = $mod CTRL, left, focusmonitor, l
bind = $mod CTRL, right, focusmonitor, r
bind = $mod CTRL, H, focusmonitor, l
bind = $mod CTRL, J, focusmonitor, d
bind = $mod CTRL, K, focusmonitor, u
bind = $mod CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = $mod SHIFT CTRL, left, movewindow, mon:l
bind = $mod SHIFT CTRL, down, movewindow, mon:d
bind = $mod SHIFT CTRL, up, movewindow, mon:u
bind = $mod SHIFT CTRL, right, movewindow, mon:r
bind = $mod SHIFT CTRL, H, movewindow, mon:l
bind = $mod SHIFT CTRL, J, movewindow, mon:d
bind = $mod SHIFT CTRL, K, movewindow, mon:u
bind = $mod SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = $mod, Page_Down, workspace, e+1
bind = $mod, Page_Up, workspace, e-1
bind = $mod, U, workspace, e+1
bind = $mod, I, workspace, e-1
bind = $mod CTRL, down, movetoworkspace, e+1
bind = $mod CTRL, up, movetoworkspace, e-1
bind = $mod CTRL, U, movetoworkspace, e+1
bind = $mod CTRL, I, movetoworkspace, e-1
# === Move Workspaces ===
bind = $mod SHIFT, Page_Down, movetoworkspace, e+1
bind = $mod SHIFT, Page_Up, movetoworkspace, e-1
bind = $mod SHIFT, U, movetoworkspace, e+1
bind = $mod SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = $mod, mouse_down, workspace, e+1
bind = $mod, mouse_up, workspace, e-1
bind = $mod CTRL, mouse_down, movetoworkspace, e+1
bind = $mod CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = $mod, 1, workspace, 1
bind = $mod, 2, workspace, 2
bind = $mod, 3, workspace, 3
bind = $mod, 4, workspace, 4
bind = $mod, 5, workspace, 5
bind = $mod, 6, workspace, 6
bind = $mod, 7, workspace, 7
bind = $mod, 8, workspace, 8
bind = $mod, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = $mod SHIFT, 1, movetoworkspace, 1
bind = $mod SHIFT, 2, movetoworkspace, 2
bind = $mod SHIFT, 3, movetoworkspace, 3
bind = $mod SHIFT, 4, movetoworkspace, 4
bind = $mod SHIFT, 5, movetoworkspace, 5
bind = $mod SHIFT, 6, movetoworkspace, 6
bind = $mod SHIFT, 7, movetoworkspace, 7
bind = $mod SHIFT, 8, movetoworkspace, 8
bind = $mod SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = $mod, bracketleft, layoutmsg, preselect l
bind = $mod, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = $mod, R, layoutmsg, togglesplit
bind = $mod CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = $mod, mouse:272, Move window, movewindow
bindmd = $mod, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = $mod, code:20, Expand window left, resizeactive, -100 0
bindd = $mod, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = $mod, minus, resizeactive, -10% 0
binde = $mod, equal, resizeactive, 10% 0
binde = $mod SHIFT, minus, resizeactive, 0 -10%
binde = $mod SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , XF86Launch1, exec, grimblast copy area
bind = CTRL, XF86Launch1, exec, grimblast copy screen
bind = ALT, XF86Launch1, exec, grimblast copy active
bind = , Print, exec, grimblast copy area
bind = CTRL, Print, exec, grimblast copy screen
bind = ALT, Print, exec, grimblast copy active
# === System Controls ===
bind = $mod SHIFT, P, dpms, off

View File

@@ -0,0 +1,24 @@
tab_bar_edge top
tab_bar_style powerline
tab_powerline_style slanted
tab_bar_align left
tab_bar_min_tabs 2
tab_bar_margin_width 0.0
tab_bar_margin_height 2.5 1.5
tab_bar_margin_color #101418
tab_bar_background #101418
active_tab_foreground #cfe5ff
active_tab_background #124a73
active_tab_font_style bold
inactive_tab_foreground #c2c7cf
inactive_tab_background #101418
inactive_tab_font_style normal
tab_activity_symbol " ● "
tab_numbers_style 1
tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]"
active_tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]"

View File

@@ -0,0 +1,24 @@
cursor #e0e2e8
cursor_text_color #c2c7cf
foreground #e0e2e8
background #101418
selection_foreground #243240
selection_background #b9c8da
url_color #9dcbfb
color0 #101418
color1 #d75a59
color2 #8ed88c
color3 #e0d99d
color4 #4087bc
color5 #839fbc
color6 #9dcbfb
color7 #abb2bf
color8 #5c6370
color9 #e57e7e
color10 #a2e5a0
color11 #efe9b3
color12 #a7d9ff
color13 #3d8197
color14 #5c7ba3
color15 #ffffff

View File

@@ -0,0 +1,37 @@
# Font Configuration
font_size 12.0
# Window Configuration
window_padding_width 12
background_opacity 1.0
background_blur 32
hide_window_decorations yes
# Cursor Configuration
cursor_shape block
cursor_blink_interval 1
# Scrollback
scrollback_lines 3000
# Terminal features
copy_on_select yes
strip_trailing_spaces smart
# Key bindings for common actions
map ctrl+shift+n new_window
map ctrl+t new_tab
map ctrl+plus change_font_size all +1.0
map ctrl+minus change_font_size all -1.0
map ctrl+0 change_font_size all 0
# Tab configuration
tab_bar_style powerline
tab_bar_align left
# Shell integration
shell_integration enabled
# Dank color generation
include dank-tabs.conf
include dank-theme.conf

View File

@@ -0,0 +1,418 @@
// This config is in the KDL format: https://kdl.dev
// "/-" comments out the following node.
// Check the wiki for a full description of the configuration:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
config-notification {
disable-failed
}
gestures {
hot-corners {
off
}
}
// Input device configuration.
// Find the full list of options on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Input
input {
keyboard {
xkb {
}
numlock
}
touchpad {
}
mouse {
}
trackpoint {
}
}
// You can configure outputs by their name, which you can find
// by running `niri msg outputs` while inside a niri instance.
// The built-in laptop monitor is usually called "eDP-1".
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Outputs
// Remember to uncomment the node by removing "/-"!
/-output "eDP-2" {
mode "2560x1600@239.998993"
position x=2560 y=0
variable-refresh-rate
}
// Settings that influence how windows are positioned and sized.
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Layout
layout {
// Set gaps around windows in logical pixels.
gaps 5
background-color "transparent"
// When to center a column when changing focus, options are:
// - "never", default behavior, focusing an off-screen column will keep at the left
// or right edge of the screen.
// - "always", the focused column will always be centered.
// - "on-overflow", focusing a column will center it if it doesn't fit
// together with the previously focused column.
center-focused-column "never"
// You can customize the widths that "switch-preset-column-width" (Mod+R) toggles between.
preset-column-widths {
// Proportion sets the width as a fraction of the output width, taking gaps into account.
// For example, you can perfectly fit four windows sized "proportion 0.25" on an output.
// The default preset widths are 1/3, 1/2 and 2/3 of the output.
proportion 0.33333
proportion 0.5
proportion 0.66667
// Fixed sets the width in logical pixels exactly.
// fixed 1920
}
// You can also customize the heights that "switch-preset-window-height" (Mod+Shift+R) toggles between.
// preset-window-heights { }
// You can change the default width of the new windows.
default-column-width { proportion 0.5; }
// If you leave the brackets empty, the windows themselves will decide their initial width.
// default-column-width {}
// By default focus ring and border are rendered as a solid background rectangle
// behind windows. That is, they will show up through semitransparent windows.
// This is because windows using client-side decorations can have an arbitrary shape.
//
// If you don't like that, you should uncomment `prefer-no-csd` below.
// Niri will draw focus ring and border *around* windows that agree to omit their
// client-side decorations.
//
// Alternatively, you can override it with a window rule called
// `draw-border-with-background`.
border {
off
width 4
active-color "#707070" // Neutral gray
inactive-color "#d0d0d0" // Light gray
urgent-color "#cc4444" // Softer red
}
focus-ring {
width 2
active-color "#808080" // Medium gray
inactive-color "#505050" // Dark gray
}
shadow {
softness 30
spread 5
offset x=0 y=5
color "#0007"
}
struts {
}
}
layer-rule {
match namespace="^quickshell$"
place-within-backdrop true
}
overview {
workspace-shadow {
off
}
}
// Add lines like this to spawn processes at startup.
// Note that running niri as a session supports xdg-desktop-autostart,
// which may be more convenient to use.
// See the binds section below for more spawn examples.
// This line starts waybar, a commonly used bar for Wayland compositors.
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
spawn-at-startup "dms" "run"
spawn-at-startup "{{POLKIT_AGENT_PATH}}"
environment {
XDG_CURRENT_DESKTOP "niri"
QT_QPA_PLATFORM "wayland"
ELECTRON_OZONE_PLATFORM_HINT "auto"
QT_QPA_PLATFORMTHEME "gtk3"
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
TERMINAL "{{TERMINAL_COMMAND}}"
}
hotkey-overlay {
skip-at-startup
}
prefer-no-csd
screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png"
animations {
workspace-switch {
spring damping-ratio=0.80 stiffness=523 epsilon=0.0001
}
window-open {
duration-ms 150
curve "ease-out-expo"
}
window-close {
duration-ms 150
curve "ease-out-quad"
}
horizontal-view-movement {
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
}
window-movement {
spring damping-ratio=0.75 stiffness=323 epsilon=0.0001
}
window-resize {
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
}
config-notification-open-close {
spring damping-ratio=0.65 stiffness=923 epsilon=0.001
}
screenshot-ui-open {
duration-ms 200
curve "ease-out-quad"
}
overview-open-close {
spring damping-ratio=0.85 stiffness=800 epsilon=0.0001
}
}
// Window rules let you adjust behavior for individual windows.
// Find more information on the wiki:
// https://github.com/YaLTeR/niri/wiki/Configuration:-Window-Rules
// Work around WezTerm's initial configure bug
// by setting an empty default-column-width.
window-rule {
// This regular expression is intentionally made as specific as possible,
// since this is the default config, and we want no false positives.
// You can get away with just app-id="wezterm" if you want.
match app-id=r#"^org\.wezfurlong\.wezterm$"#
default-column-width {}
}
window-rule {
match app-id=r#"^org\.gnome\."#
draw-border-with-background false
geometry-corner-radius 12
clip-to-geometry true
}
window-rule {
match app-id=r#"^gnome-control-center$"#
match app-id=r#"^pavucontrol$"#
match app-id=r#"^nm-connection-editor$"#
default-column-width { proportion 0.5; }
open-floating false
}
window-rule {
match app-id=r#"^gnome-calculator$"#
match app-id=r#"^galculator$"#
match app-id=r#"^blueman-manager$"#
match app-id=r#"^org\.gnome\.Nautilus$"#
match app-id=r#"^steam$"#
match app-id=r#"^xdg-desktop-portal$"#
open-floating true
}
window-rule {
match app-id=r#"^org\.wezfurlong\.wezterm$"#
match app-id="Alacritty"
match app-id="zen"
match app-id="com.mitchellh.ghostty"
match app-id="kitty"
draw-border-with-background false
}
window-rule {
match is-active=false
opacity 0.9
}
window-rule {
match app-id=r#"firefox$"# title="^Picture-in-Picture$"
match app-id="zoom"
open-floating true
}
window-rule {
geometry-corner-radius 12
clip-to-geometry true
}
binds {
// === System & Overview ===
Mod+D { spawn "niri" "msg" "action" "toggle-overview"; }
Mod+Tab repeat=false { toggle-overview; }
Mod+Shift+Slash { show-hotkey-overlay; }
// === Application Launchers ===
Mod+T hotkey-overlay-title="Open Terminal" { spawn "{{TERMINAL_COMMAND}}"; }
Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle";
}
Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle";
}
Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "toggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
}
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
// === Security ===
Mod+Alt+L hotkey-overlay-title="Lock Screen" {
spawn "dms" "ipc" "call" "lock" "lock";
}
Mod+Shift+E { quit; }
Ctrl+Alt+Delete hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "toggle";
}
// === Audio Controls ===
XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "increment" "3";
}
XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "decrement" "3";
}
XF86AudioMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "mute";
}
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
// === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "increment" "5" "";
}
XF86MonBrightnessDown allow-when-locked=true {
spawn "dms" "ipc" "call" "brightness" "decrement" "5" "";
}
// === Window Management ===
Mod+Q repeat=false { close-window; }
Mod+F { maximize-column; }
Mod+Shift+F { fullscreen-window; }
Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; }
// === Focus Navigation ===
Mod+Left { focus-column-left; }
Mod+Down { focus-window-down; }
Mod+Up { focus-window-up; }
Mod+Right { focus-column-right; }
Mod+H { focus-column-left; }
Mod+J { focus-window-down; }
Mod+K { focus-window-up; }
Mod+L { focus-column-right; }
// === Window Movement ===
Mod+Shift+Left { move-column-left; }
Mod+Shift+Down { move-window-down; }
Mod+Shift+Up { move-window-up; }
Mod+Shift+Right { move-column-right; }
Mod+Shift+H { move-column-left; }
Mod+Shift+J { move-window-down; }
Mod+Shift+K { move-window-up; }
Mod+Shift+L { move-column-right; }
// === Column Navigation ===
Mod+Home { focus-column-first; }
Mod+End { focus-column-last; }
Mod+Ctrl+Home { move-column-to-first; }
Mod+Ctrl+End { move-column-to-last; }
// === Monitor Navigation ===
Mod+Ctrl+Left { focus-monitor-left; }
//Mod+Ctrl+Down { focus-monitor-down; }
//Mod+Ctrl+Up { focus-monitor-up; }
Mod+Ctrl+Right { focus-monitor-right; }
Mod+Ctrl+H { focus-monitor-left; }
Mod+Ctrl+J { focus-monitor-down; }
Mod+Ctrl+K { focus-monitor-up; }
Mod+Ctrl+L { focus-monitor-right; }
// === Move to Monitor ===
Mod+Shift+Ctrl+Left { move-column-to-monitor-left; }
Mod+Shift+Ctrl+Down { move-column-to-monitor-down; }
Mod+Shift+Ctrl+Up { move-column-to-monitor-up; }
Mod+Shift+Ctrl+Right { move-column-to-monitor-right; }
Mod+Shift+Ctrl+H { move-column-to-monitor-left; }
Mod+Shift+Ctrl+J { move-column-to-monitor-down; }
Mod+Shift+Ctrl+K { move-column-to-monitor-up; }
Mod+Shift+Ctrl+L { move-column-to-monitor-right; }
// === Workspace Navigation ===
Mod+Page_Down { focus-workspace-down; }
Mod+Page_Up { focus-workspace-up; }
Mod+U { focus-workspace-down; }
Mod+I { focus-workspace-up; }
Mod+Ctrl+Down { move-column-to-workspace-down; }
Mod+Ctrl+Up { move-column-to-workspace-up; }
Mod+Ctrl+U { move-column-to-workspace-down; }
Mod+Ctrl+I { move-column-to-workspace-up; }
// === Move Workspaces ===
Mod+Shift+Page_Down { move-workspace-down; }
Mod+Shift+Page_Up { move-workspace-up; }
Mod+Shift+U { move-workspace-down; }
Mod+Shift+I { move-workspace-up; }
// === Mouse Wheel Navigation ===
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
Mod+WheelScrollUp cooldown-ms=150 { focus-workspace-up; }
Mod+Ctrl+WheelScrollDown cooldown-ms=150 { move-column-to-workspace-down; }
Mod+Ctrl+WheelScrollUp cooldown-ms=150 { move-column-to-workspace-up; }
Mod+WheelScrollRight { focus-column-right; }
Mod+WheelScrollLeft { focus-column-left; }
Mod+Ctrl+WheelScrollRight { move-column-right; }
Mod+Ctrl+WheelScrollLeft { move-column-left; }
Mod+Shift+WheelScrollDown { focus-column-right; }
Mod+Shift+WheelScrollUp { focus-column-left; }
Mod+Ctrl+Shift+WheelScrollDown { move-column-right; }
Mod+Ctrl+Shift+WheelScrollUp { move-column-left; }
// === Numbered Workspaces ===
Mod+1 { focus-workspace 1; }
Mod+2 { focus-workspace 2; }
Mod+3 { focus-workspace 3; }
Mod+4 { focus-workspace 4; }
Mod+5 { focus-workspace 5; }
Mod+6 { focus-workspace 6; }
Mod+7 { focus-workspace 7; }
Mod+8 { focus-workspace 8; }
Mod+9 { focus-workspace 9; }
// === Move to Numbered Workspaces ===
Mod+Shift+1 { move-column-to-workspace 1; }
Mod+Shift+2 { move-column-to-workspace 2; }
Mod+Shift+3 { move-column-to-workspace 3; }
Mod+Shift+4 { move-column-to-workspace 4; }
Mod+Shift+5 { move-column-to-workspace 5; }
Mod+Shift+6 { move-column-to-workspace 6; }
Mod+Shift+7 { move-column-to-workspace 7; }
Mod+Shift+8 { move-column-to-workspace 8; }
Mod+Shift+9 { move-column-to-workspace 9; }
// === Column Management ===
Mod+BracketLeft { consume-or-expel-window-left; }
Mod+BracketRight { consume-or-expel-window-right; }
Mod+Period { expel-window-from-column; }
// === Sizing & Layout ===
Mod+R { switch-preset-column-width; }
Mod+Shift+R { switch-preset-window-height; }
Mod+Ctrl+R { reset-window-height; }
Mod+Ctrl+F { expand-column-to-available-width; }
Mod+C { center-column; }
Mod+Ctrl+C { center-visible-columns; }
// === Manual Sizing ===
Mod+Minus { set-column-width "-10%"; }
Mod+Equal { set-column-width "+10%"; }
Mod+Shift+Minus { set-window-height "-10%"; }
Mod+Shift+Equal { set-window-height "+10%"; }
// === Screenshots ===
XF86Launch1 { screenshot; }
Ctrl+XF86Launch1 { screenshot-screen; }
Alt+XF86Launch1 { screenshot-window; }
Print { screenshot; }
Ctrl+Print { screenshot-screen; }
Alt+Print { screenshot-window; }
// === System Controls ===
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
Mod+Shift+P { power-off-monitors; }
}
debug {
honor-xdg-activation-with-invalid-serial
}

View File

@@ -0,0 +1,6 @@
package config
import _ "embed"
//go:embed embedded/hyprland.conf
var HyprlandConfig string

View File

@@ -0,0 +1,6 @@
package config
import _ "embed"
//go:embed embedded/niri.kdl
var NiriConfig string

View File

@@ -0,0 +1,24 @@
package config
import _ "embed"
//go:embed embedded/ghostty.conf
var GhosttyConfig string
//go:embed embedded/ghostty-colors.conf
var GhosttyColorConfig string
//go:embed embedded/kitty.conf
var KittyConfig string
//go:embed embedded/kitty-theme.conf
var KittyThemeConfig string
//go:embed embedded/kitty-tabs.conf
var KittyTabsConfig string
//go:embed embedded/alacritty.toml
var AlacrittyConfig string
//go:embed embedded/alacritty-theme.toml
var AlacrittyThemeConfig string

View File

@@ -0,0 +1,453 @@
package dank16
import (
"fmt"
"math"
"github.com/lucasb-eyer/go-colorful"
)
type RGB struct {
R, G, B float64
}
type HSV struct {
H, S, V float64
}
func HexToRGB(hex string) RGB {
if hex[0] == '#' {
hex = hex[1:]
}
var r, g, b uint8
fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b)
return RGB{
R: float64(r) / 255.0,
G: float64(g) / 255.0,
B: float64(b) / 255.0,
}
}
func RGBToHex(rgb RGB) string {
r := math.Max(0, math.Min(1, rgb.R))
g := math.Max(0, math.Min(1, rgb.G))
b := math.Max(0, math.Min(1, rgb.B))
return fmt.Sprintf("#%02x%02x%02x", int(r*255), int(g*255), int(b*255))
}
func RGBToHSV(rgb RGB) HSV {
max := math.Max(math.Max(rgb.R, rgb.G), rgb.B)
min := math.Min(math.Min(rgb.R, rgb.G), rgb.B)
delta := max - min
var h float64
if delta == 0 {
h = 0
} else if max == rgb.R {
h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0
} else if max == rgb.G {
h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0
} else {
h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0
}
if h < 0 {
h += 1.0
}
var s float64
if max == 0 {
s = 0
} else {
s = delta / max
}
return HSV{H: h, S: s, V: max}
}
func HSVToRGB(hsv HSV) RGB {
h := hsv.H * 6.0
c := hsv.V * hsv.S
x := c * (1.0 - math.Abs(math.Mod(h, 2.0)-1.0))
m := hsv.V - c
var r, g, b float64
switch int(h) {
case 0:
r, g, b = c, x, 0
case 1:
r, g, b = x, c, 0
case 2:
r, g, b = 0, c, x
case 3:
r, g, b = 0, x, c
case 4:
r, g, b = x, 0, c
case 5:
r, g, b = c, 0, x
default:
r, g, b = c, 0, x
}
return RGB{R: r + m, G: g + m, B: b + m}
}
func sRGBToLinear(c float64) float64 {
if c <= 0.04045 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
func Luminance(hex string) float64 {
rgb := HexToRGB(hex)
return 0.2126*sRGBToLinear(rgb.R) + 0.7152*sRGBToLinear(rgb.G) + 0.0722*sRGBToLinear(rgb.B)
}
func ContrastRatio(hexFg, hexBg string) float64 {
lumFg := Luminance(hexFg)
lumBg := Luminance(hexBg)
lighter := math.Max(lumFg, lumBg)
darker := math.Min(lumFg, lumBg)
return (lighter + 0.05) / (darker + 0.05)
}
func getLstar(hex string) float64 {
rgb := HexToRGB(hex)
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
L, _, _ := col.Lab()
return L * 100.0 // go-colorful uses 0-1, we need 0-100 for DPS
}
// Lab to hex, clamping if needed
func labToHex(L, a, b float64) string {
c := colorful.Lab(L/100.0, a, b) // back to 0-1 for go-colorful
r, g, b2 := c.Clamped().RGB255()
return fmt.Sprintf("#%02x%02x%02x", r, g, b2)
}
// Adjust brightness while keeping the same hue
func retoneToL(hex string, Ltarget float64) string {
rgb := HexToRGB(hex)
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
L, a, b := col.Lab()
L100 := L * 100.0
scale := 1.0
if L100 != 0 {
scale = Ltarget / L100
}
a2, b2 := a*scale, b*scale
// Don't let it get too saturated
maxChroma := 0.4
if math.Hypot(a2, b2) > maxChroma {
k := maxChroma / math.Hypot(a2, b2)
a2 *= k
b2 *= k
}
return labToHex(Ltarget, a2, b2)
}
func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 {
Lf := getLstar(hexFg)
Lb := getLstar(hexBg)
phi := 1.618
inv := 0.618
lc := math.Pow(math.Abs(math.Pow(Lb, phi)-math.Pow(Lf, phi)), inv)*1.414 - 40
if negativePolarity {
lc += 5
}
return lc
}
func DeltaPhiStarContrast(hexFg, hexBg string, isLightMode bool) float64 {
negativePolarity := !isLightMode
return DeltaPhiStar(hexFg, hexBg, negativePolarity)
}
func EnsureContrast(hexColor, hexBg string, minRatio float64, isLightMode bool) string {
currentRatio := ContrastRatio(hexColor, hexBg)
if currentRatio >= minRatio {
return hexColor
}
rgb := HexToRGB(hexColor)
hsv := RGBToHSV(rgb)
for step := 1; step < 30; step++ {
delta := float64(step) * 0.02
if isLightMode {
newV := math.Max(0, hsv.V-delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
newV = math.Min(1, hsv.V+delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
} else {
newV := math.Min(1, hsv.V+delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
newV = math.Max(0, hsv.V-delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if ContrastRatio(candidate, hexBg) >= minRatio {
return candidate
}
}
}
return hexColor
}
func EnsureContrastDPS(hexColor, hexBg string, minLc float64, isLightMode bool) string {
currentLc := DeltaPhiStarContrast(hexColor, hexBg, isLightMode)
if currentLc >= minLc {
return hexColor
}
rgb := HexToRGB(hexColor)
hsv := RGBToHSV(rgb)
for step := 1; step < 50; step++ {
delta := float64(step) * 0.015
if isLightMode {
newV := math.Max(0, hsv.V-delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
newV = math.Min(1, hsv.V+delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
} else {
newV := math.Min(1, hsv.V+delta)
candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
newV = math.Max(0, hsv.V-delta)
candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV}))
if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc {
return candidate
}
}
}
return hexColor
}
// Nudge L* until contrast is good enough. Keeps hue intact unlike HSV fiddling.
func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode bool) string {
current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode)
if current >= minLc {
return hexColor
}
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab()
dir := 1.0
if isLightMode {
dir = -1.0 // light mode = darker text
}
step := 0.5
for i := 0; i < 120; i++ {
Lf = math.Max(0, math.Min(100, Lf+dir*step))
cand := labToHex(Lf, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
return cand
}
}
return hexColor
}
type PaletteOptions struct {
IsLight bool
Background string
UseDPS bool
}
func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOptions) string {
if opts.UseDPS {
return EnsureContrastDPSLstar(hexColor, hexBg, target, opts.IsLight)
}
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
if isLight {
containerV := math.Min(hsv.V*1.77, 1.0)
containerS := hsv.S * 0.32
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
containerV := hsv.V * 0.463
containerS := math.Min(hsv.S*1.834, 1.0)
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
func GeneratePalette(primaryColor string, opts PaletteOptions) []string {
baseColor := DeriveContainer(primaryColor, opts.IsLight)
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
palette := make([]string, 0, 16)
var normalTextTarget, secondaryTarget float64
if opts.UseDPS {
normalTextTarget = 40.0
secondaryTarget = 35.0
} else {
normalTextTarget = 4.5
secondaryTarget = 3.0
}
var bgColor string
if opts.Background != "" {
bgColor = opts.Background
} else if opts.IsLight {
bgColor = "#f8f8f8"
} else {
bgColor = "#1a1a1a"
}
palette = append(palette, bgColor)
hueShift := (hsv.H - 0.6) * 0.12
satBoost := 1.15
redH := math.Mod(0.0+hueShift+1.0, 1.0)
var redColor string
if opts.IsLight {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.80*satBoost, 1.0), V: 0.55}))
palette = append(palette, ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
} else {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.65*satBoost, 1.0), V: 0.80}))
palette = append(palette, ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
}
greenH := math.Mod(0.33+hueShift+1.0, 1.0)
var greenColor string
if opts.IsLight {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.9, 0.80)*satBoost, 1.0), V: 0.45}))
palette = append(palette, ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
} else {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.42*satBoost, 1.0), V: 0.84}))
palette = append(palette, ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
}
yellowH := math.Mod(0.15+hueShift+1.0, 1.0)
var yellowColor string
if opts.IsLight {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.75*satBoost, 1.0), V: 0.50}))
palette = append(palette, ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
} else {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.38*satBoost, 1.0), V: 0.86}))
palette = append(palette, ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
}
var blueColor string
if opts.IsLight {
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.9, 0.7), V: hsv.V * 1.1}))
palette = append(palette, ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
} else {
blueColor = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: math.Max(hsv.S*0.8, 0.6), V: math.Min(hsv.V*1.6, 1.0)}))
palette = append(palette, ensureContrastAuto(blueColor, bgColor, normalTextTarget, opts))
}
magH := hsv.H - 0.03
if magH < 0 {
magH += 1.0
}
var magColor string
hr := HexToRGB(primaryColor)
hh := RGBToHSV(hr)
if opts.IsLight {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Max(hh.S*0.9, 0.7), V: hh.V * 0.85}))
palette = append(palette, ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
} else {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: hh.S * 0.8, V: hh.V * 0.75}))
palette = append(palette, ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
}
cyanH := hsv.H + 0.08
if cyanH > 1.0 {
cyanH -= 1.0
}
palette = append(palette, ensureContrastAuto(primaryColor, bgColor, normalTextTarget, opts))
if opts.IsLight {
palette = append(palette, "#1a1a1a")
palette = append(palette, "#2e2e2e")
} else {
palette = append(palette, "#abb2bf")
palette = append(palette, "#5c6370")
}
if opts.IsLight {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.70*satBoost, 1.0), V: 0.65}))
palette = append(palette, ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(math.Max(hsv.S*0.85, 0.75)*satBoost, 1.0), V: 0.55}))
palette = append(palette, ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.68*satBoost, 1.0), V: 0.60}))
palette = append(palette, ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
hr := HexToRGB(primaryColor)
hh := RGBToHSV(hr)
brightBlue := RGBToHex(HSVToRGB(HSV{H: hh.H, S: math.Min(hh.S*1.1, 1.0), V: math.Min(hh.V*1.2, 1.0)}))
palette = append(palette, ensureContrastAuto(brightBlue, bgColor, secondaryTarget, opts))
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.9, 0.75), V: math.Min(hsv.V*1.25, 1.0)}))
palette = append(palette, ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
brightCyan := RGBToHex(HSVToRGB(HSV{H: cyanH, S: math.Max(hsv.S*0.75, 0.65), V: math.Min(hsv.V*1.25, 1.0)}))
palette = append(palette, ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
} else {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.50*satBoost, 1.0), V: 0.88}))
palette = append(palette, ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.35*satBoost, 1.0), V: 0.88}))
palette = append(palette, ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.30*satBoost, 1.0), V: 0.91}))
palette = append(palette, ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
// Make it way brighter for type names in dark mode
brightBlue := retoneToL(primaryColor, 85.0)
palette = append(palette, brightBlue)
brightMag := RGBToHex(HSVToRGB(HSV{H: magH, S: math.Max(hsv.S*0.7, 0.6), V: math.Min(hsv.V*1.3, 0.9)}))
palette = append(palette, ensureContrastAuto(brightMag, bgColor, secondaryTarget, opts))
brightCyanH := hsv.H + 0.02
if brightCyanH > 1.0 {
brightCyanH -= 1.0
}
brightCyan := RGBToHex(HSVToRGB(HSV{H: brightCyanH, S: math.Max(hsv.S*0.6, 0.5), V: math.Min(hsv.V*1.2, 0.85)}))
palette = append(palette, ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
}
if opts.IsLight {
palette = append(palette, "#1a1a1a")
} else {
palette = append(palette, "#ffffff")
}
return palette
}

View File

@@ -0,0 +1,727 @@
package dank16
import (
"encoding/json"
"math"
"testing"
)
func TestHexToRGB(t *testing.T) {
tests := []struct {
name string
input string
expected RGB
}{
{
name: "black with hash",
input: "#000000",
expected: RGB{R: 0.0, G: 0.0, B: 0.0},
},
{
name: "white with hash",
input: "#ffffff",
expected: RGB{R: 1.0, G: 1.0, B: 1.0},
},
{
name: "red without hash",
input: "ff0000",
expected: RGB{R: 1.0, G: 0.0, B: 0.0},
},
{
name: "purple",
input: "#625690",
expected: RGB{R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412},
},
{
name: "mid gray",
input: "#808080",
expected: RGB{R: 0.5019607843137255, G: 0.5019607843137255, B: 0.5019607843137255},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HexToRGB(tt.input)
if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) {
t.Errorf("HexToRGB(%s) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestRGBToHex(t *testing.T) {
tests := []struct {
name string
input RGB
expected string
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: "#000000",
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: "#ffffff",
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: "#ff0000",
},
{
name: "clamping above 1.0",
input: RGB{R: 1.5, G: 0.5, B: 0.5},
expected: "#ff7f7f",
},
{
name: "clamping below 0.0",
input: RGB{R: -0.5, G: 0.5, B: 0.5},
expected: "#007f7f",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHex(tt.input)
if result != tt.expected {
t.Errorf("RGBToHex(%v) = %s, expected %s", tt.input, result, tt.expected)
}
})
}
}
func TestRGBToHSV(t *testing.T) {
tests := []struct {
name string
input RGB
expected HSV
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: HSV{H: 0.0, S: 0.0, V: 0.0},
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: HSV{H: 0.0, S: 0.0, V: 1.0},
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: HSV{H: 0.0, S: 1.0, V: 1.0},
},
{
name: "green",
input: RGB{R: 0.0, G: 1.0, B: 0.0},
expected: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0},
},
{
name: "blue",
input: RGB{R: 0.0, G: 0.0, B: 1.0},
expected: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHSV(tt.input)
if !floatEqual(result.H, tt.expected.H) || !floatEqual(result.S, tt.expected.S) || !floatEqual(result.V, tt.expected.V) {
t.Errorf("RGBToHSV(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestHSVToRGB(t *testing.T) {
tests := []struct {
name string
input HSV
expected RGB
}{
{
name: "black",
input: HSV{H: 0.0, S: 0.0, V: 0.0},
expected: RGB{R: 0.0, G: 0.0, B: 0.0},
},
{
name: "white",
input: HSV{H: 0.0, S: 0.0, V: 1.0},
expected: RGB{R: 1.0, G: 1.0, B: 1.0},
},
{
name: "red",
input: HSV{H: 0.0, S: 1.0, V: 1.0},
expected: RGB{R: 1.0, G: 0.0, B: 0.0},
},
{
name: "green",
input: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0},
expected: RGB{R: 0.0, G: 1.0, B: 0.0},
},
{
name: "blue",
input: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0},
expected: RGB{R: 0.0, G: 0.0, B: 1.0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HSVToRGB(tt.input)
if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) {
t.Errorf("HSVToRGB(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestLuminance(t *testing.T) {
tests := []struct {
name string
input string
expected float64
}{
{
name: "black",
input: "#000000",
expected: 0.0,
},
{
name: "white",
input: "#ffffff",
expected: 1.0,
},
{
name: "red",
input: "#ff0000",
expected: 0.2126,
},
{
name: "green",
input: "#00ff00",
expected: 0.7152,
},
{
name: "blue",
input: "#0000ff",
expected: 0.0722,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Luminance(tt.input)
if !floatEqual(result, tt.expected) {
t.Errorf("Luminance(%s) = %f, expected %f", tt.input, result, tt.expected)
}
})
}
}
func TestContrastRatio(t *testing.T) {
tests := []struct {
name string
fg string
bg string
expected float64
}{
{
name: "black on white",
fg: "#000000",
bg: "#ffffff",
expected: 21.0,
},
{
name: "white on black",
fg: "#ffffff",
bg: "#000000",
expected: 21.0,
},
{
name: "same color",
fg: "#808080",
bg: "#808080",
expected: 1.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ContrastRatio(tt.fg, tt.bg)
if !floatEqual(result, tt.expected) {
t.Errorf("ContrastRatio(%s, %s) = %f, expected %f", tt.fg, tt.bg, result, tt.expected)
}
})
}
}
func TestEnsureContrast(t *testing.T) {
tests := []struct {
name string
color string
bg string
minRatio float64
isLightMode bool
}{
{
name: "already sufficient contrast dark mode",
color: "#ffffff",
bg: "#000000",
minRatio: 4.5,
isLightMode: false,
},
{
name: "already sufficient contrast light mode",
color: "#000000",
bg: "#ffffff",
minRatio: 4.5,
isLightMode: true,
},
{
name: "needs adjustment dark mode",
color: "#404040",
bg: "#1a1a1a",
minRatio: 4.5,
isLightMode: false,
},
{
name: "needs adjustment light mode",
color: "#c0c0c0",
bg: "#f8f8f8",
minRatio: 4.5,
isLightMode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EnsureContrast(tt.color, tt.bg, tt.minRatio, tt.isLightMode)
actualRatio := ContrastRatio(result, tt.bg)
if actualRatio < tt.minRatio {
t.Errorf("EnsureContrast(%s, %s, %f, %t) = %s with ratio %f, expected ratio >= %f",
tt.color, tt.bg, tt.minRatio, tt.isLightMode, result, actualRatio, tt.minRatio)
}
})
}
}
func TestGeneratePalette(t *testing.T) {
tests := []struct {
name string
base string
opts PaletteOptions
}{
{
name: "dark theme default",
base: "#625690",
opts: PaletteOptions{IsLight: false},
},
{
name: "light theme default",
base: "#625690",
opts: PaletteOptions{IsLight: true},
},
{
name: "light theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: true,
Background: "#fafafa",
},
},
{
name: "dark theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: false,
Background: "#0a0a0a",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GeneratePalette(tt.base, tt.opts)
if len(result) != 16 {
t.Errorf("GeneratePalette returned %d colors, expected 16", len(result))
}
for i, color := range result {
if len(color) != 7 || color[0] != '#' {
t.Errorf("Color at index %d (%s) is not a valid hex color", i, color)
}
}
if tt.opts.Background != "" && result[0] != tt.opts.Background {
t.Errorf("Background color = %s, expected %s", result[0], tt.opts.Background)
} else if !tt.opts.IsLight && tt.opts.Background == "" && result[0] != "#1a1a1a" {
t.Errorf("Dark mode background = %s, expected #1a1a1a", result[0])
} else if tt.opts.IsLight && tt.opts.Background == "" && result[0] != "#f8f8f8" {
t.Errorf("Light mode background = %s, expected #f8f8f8", result[0])
}
if tt.opts.IsLight && result[15] != "#1a1a1a" {
t.Errorf("Light mode foreground = %s, expected #1a1a1a", result[15])
} else if !tt.opts.IsLight && result[15] != "#ffffff" {
t.Errorf("Dark mode foreground = %s, expected #ffffff", result[15])
}
})
}
}
func TestEnrichVSCodeTheme(t *testing.T) {
colors := GeneratePalette("#625690", PaletteOptions{IsLight: false})
baseTheme := map[string]interface{}{
"name": "Test Theme",
"type": "dark",
"colors": map[string]interface{}{
"editor.background": "#000000",
},
}
themeJSON, err := json.Marshal(baseTheme)
if err != nil {
t.Fatalf("Failed to marshal base theme: %v", err)
}
result, err := EnrichVSCodeTheme(themeJSON, colors)
if err != nil {
t.Fatalf("EnrichVSCodeTheme failed: %v", err)
}
var enriched map[string]interface{}
if err := json.Unmarshal(result, &enriched); err != nil {
t.Fatalf("Failed to unmarshal result: %v", err)
}
colorsMap, ok := enriched["colors"].(map[string]interface{})
if !ok {
t.Fatal("colors is not a map")
}
terminalColors := []string{
"terminal.ansiBlack",
"terminal.ansiRed",
"terminal.ansiGreen",
"terminal.ansiYellow",
"terminal.ansiBlue",
"terminal.ansiMagenta",
"terminal.ansiCyan",
"terminal.ansiWhite",
"terminal.ansiBrightBlack",
"terminal.ansiBrightRed",
"terminal.ansiBrightGreen",
"terminal.ansiBrightYellow",
"terminal.ansiBrightBlue",
"terminal.ansiBrightMagenta",
"terminal.ansiBrightCyan",
"terminal.ansiBrightWhite",
}
for i, key := range terminalColors {
if val, ok := colorsMap[key]; !ok {
t.Errorf("Missing terminal color: %s", key)
} else if val != colors[i] {
t.Errorf("%s = %s, expected %s", key, val, colors[i])
}
}
if colorsMap["editor.background"] != "#000000" {
t.Error("Original theme colors should be preserved")
}
}
func TestEnrichVSCodeThemeInvalidJSON(t *testing.T) {
colors := GeneratePalette("#625690", PaletteOptions{IsLight: false})
invalidJSON := []byte("{invalid json")
_, err := EnrichVSCodeTheme(invalidJSON, colors)
if err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
func TestRoundTripConversion(t *testing.T) {
testColors := []string{"#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#625690", "#808080"}
for _, hex := range testColors {
t.Run(hex, func(t *testing.T) {
rgb := HexToRGB(hex)
result := RGBToHex(rgb)
if result != hex {
t.Errorf("Round trip %s -> RGB -> %s failed", hex, result)
}
})
}
}
func TestRGBHSVRoundTrip(t *testing.T) {
testCases := []RGB{
{R: 0.0, G: 0.0, B: 0.0},
{R: 1.0, G: 1.0, B: 1.0},
{R: 1.0, G: 0.0, B: 0.0},
{R: 0.0, G: 1.0, B: 0.0},
{R: 0.0, G: 0.0, B: 1.0},
{R: 0.5, G: 0.5, B: 0.5},
{R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412},
}
for _, rgb := range testCases {
t.Run("", func(t *testing.T) {
hsv := RGBToHSV(rgb)
result := HSVToRGB(hsv)
if !floatEqual(result.R, rgb.R) || !floatEqual(result.G, rgb.G) || !floatEqual(result.B, rgb.B) {
t.Errorf("Round trip RGB->HSV->RGB failed: %v -> %v -> %v", rgb, hsv, result)
}
})
}
}
func floatEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func TestDeltaPhiStar(t *testing.T) {
tests := []struct {
name string
fg string
bg string
negativePolarity bool
minExpected float64
}{
{
name: "white on black (negative polarity)",
fg: "#ffffff",
bg: "#000000",
negativePolarity: true,
minExpected: 100.0,
},
{
name: "black on white (positive polarity)",
fg: "#000000",
bg: "#ffffff",
negativePolarity: false,
minExpected: 100.0,
},
{
name: "low contrast same color",
fg: "#808080",
bg: "#808080",
negativePolarity: false,
minExpected: -40.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeltaPhiStar(tt.fg, tt.bg, tt.negativePolarity)
if result < tt.minExpected {
t.Errorf("DeltaPhiStar(%s, %s, %v) = %f, expected >= %f",
tt.fg, tt.bg, tt.negativePolarity, result, tt.minExpected)
}
})
}
}
func TestDeltaPhiStarContrast(t *testing.T) {
tests := []struct {
name string
fg string
bg string
isLightMode bool
minExpected float64
}{
{
name: "white on black (dark mode)",
fg: "#ffffff",
bg: "#000000",
isLightMode: false,
minExpected: 100.0,
},
{
name: "black on white (light mode)",
fg: "#000000",
bg: "#ffffff",
isLightMode: true,
minExpected: 100.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeltaPhiStarContrast(tt.fg, tt.bg, tt.isLightMode)
if result < tt.minExpected {
t.Errorf("DeltaPhiStarContrast(%s, %s, %v) = %f, expected >= %f",
tt.fg, tt.bg, tt.isLightMode, result, tt.minExpected)
}
})
}
}
func TestEnsureContrastDPS(t *testing.T) {
tests := []struct {
name string
color string
bg string
minLc float64
isLightMode bool
}{
{
name: "already sufficient contrast dark mode",
color: "#ffffff",
bg: "#000000",
minLc: 60.0,
isLightMode: false,
},
{
name: "already sufficient contrast light mode",
color: "#000000",
bg: "#ffffff",
minLc: 60.0,
isLightMode: true,
},
{
name: "needs adjustment dark mode",
color: "#404040",
bg: "#1a1a1a",
minLc: 60.0,
isLightMode: false,
},
{
name: "needs adjustment light mode",
color: "#c0c0c0",
bg: "#f8f8f8",
minLc: 60.0,
isLightMode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EnsureContrastDPS(tt.color, tt.bg, tt.minLc, tt.isLightMode)
actualLc := DeltaPhiStarContrast(result, tt.bg, tt.isLightMode)
if actualLc < tt.minLc {
t.Errorf("EnsureContrastDPS(%s, %s, %f, %t) = %s with Lc %f, expected Lc >= %f",
tt.color, tt.bg, tt.minLc, tt.isLightMode, result, actualLc, tt.minLc)
}
})
}
}
func TestGeneratePaletteWithDPS(t *testing.T) {
tests := []struct {
name string
base string
opts PaletteOptions
}{
{
name: "dark theme with DPS",
base: "#625690",
opts: PaletteOptions{IsLight: false, UseDPS: true},
},
{
name: "light theme with DPS",
base: "#625690",
opts: PaletteOptions{IsLight: true, UseDPS: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GeneratePalette(tt.base, tt.opts)
if len(result) != 16 {
t.Errorf("GeneratePalette returned %d colors, expected 16", len(result))
}
for i, color := range result {
if len(color) != 7 || color[0] != '#' {
t.Errorf("Color at index %d (%s) is not a valid hex color", i, color)
}
}
bgColor := result[0]
for i := 1; i < 8; i++ {
lc := DeltaPhiStarContrast(result[i], bgColor, tt.opts.IsLight)
minLc := 30.0
if lc < minLc && lc > 0 {
t.Errorf("Color %d (%s) has insufficient DPS contrast %f with background %s (expected >= %f)",
i, result[i], lc, bgColor, minLc)
}
}
})
}
}
func TestDeriveContainer(t *testing.T) {
tests := []struct {
name string
primary string
isLight bool
expected string
}{
{
name: "dark mode",
primary: "#ccbdff",
isLight: false,
expected: "#4a3e76",
},
{
name: "light mode",
primary: "#625690",
isLight: true,
expected: "#e7deff",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeriveContainer(tt.primary, tt.isLight)
resultRGB := HexToRGB(result)
expectedRGB := HexToRGB(tt.expected)
rDiff := math.Abs(resultRGB.R - expectedRGB.R)
gDiff := math.Abs(resultRGB.G - expectedRGB.G)
bDiff := math.Abs(resultRGB.B - expectedRGB.B)
tolerance := 0.02
if rDiff > tolerance || gDiff > tolerance || bDiff > tolerance {
t.Errorf("DeriveContainer(%s, %v) = %s, expected %s (RGB diff: R:%.4f G:%.4f B:%.4f)",
tt.primary, tt.isLight, result, tt.expected, rDiff, gDiff, bDiff)
}
})
}
}
func TestContrastAlgorithmComparison(t *testing.T) {
base := "#625690"
optsWCAG := PaletteOptions{IsLight: false, UseDPS: false}
optsDPS := PaletteOptions{IsLight: false, UseDPS: true}
paletteWCAG := GeneratePalette(base, optsWCAG)
paletteDPS := GeneratePalette(base, optsDPS)
if len(paletteWCAG) != 16 || len(paletteDPS) != 16 {
t.Fatal("Both palettes should have 16 colors")
}
if paletteWCAG[0] != paletteDPS[0] {
t.Errorf("Background colors differ: WCAG=%s, DPS=%s", paletteWCAG[0], paletteDPS[0])
}
differentCount := 0
for i := 0; i < 16; i++ {
if paletteWCAG[i] != paletteDPS[i] {
differentCount++
}
}
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
}

View File

@@ -0,0 +1,126 @@
package dank16
import (
"encoding/json"
"fmt"
"strings"
)
func GenerateJSON(colors []string) string {
colorMap := make(map[string]string)
for i, color := range colors {
colorMap[fmt.Sprintf("color%d", i)] = color
}
marshalled, _ := json.Marshal(colorMap)
return string(marshalled)
}
func GenerateKittyTheme(colors []string) string {
kittyColors := []struct {
name string
index int
}{
{"color0", 0},
{"color1", 1},
{"color2", 2},
{"color3", 3},
{"color4", 4},
{"color5", 5},
{"color6", 6},
{"color7", 7},
{"color8", 8},
{"color9", 9},
{"color10", 10},
{"color11", 11},
{"color12", 12},
{"color13", 13},
{"color14", 14},
{"color15", 15},
}
var result strings.Builder
for _, kc := range kittyColors {
fmt.Fprintf(&result, "%s %s\n", kc.name, colors[kc.index])
}
return result.String()
}
func GenerateFootTheme(colors []string) string {
footColors := []struct {
name string
index int
}{
{"regular0", 0},
{"regular1", 1},
{"regular2", 2},
{"regular3", 3},
{"regular4", 4},
{"regular5", 5},
{"regular6", 6},
{"regular7", 7},
{"bright0", 8},
{"bright1", 9},
{"bright2", 10},
{"bright3", 11},
{"bright4", 12},
{"bright5", 13},
{"bright6", 14},
{"bright7", 15},
}
var result strings.Builder
for _, fc := range footColors {
fmt.Fprintf(&result, "%s=%s\n", fc.name, strings.TrimPrefix(colors[fc.index], "#"))
}
return result.String()
}
func GenerateAlacrittyTheme(colors []string) string {
alacrittyColors := []struct {
section string
name string
index int
}{
{"normal", "black", 0},
{"normal", "red", 1},
{"normal", "green", 2},
{"normal", "yellow", 3},
{"normal", "blue", 4},
{"normal", "magenta", 5},
{"normal", "cyan", 6},
{"normal", "white", 7},
{"bright", "black", 8},
{"bright", "red", 9},
{"bright", "green", 10},
{"bright", "yellow", 11},
{"bright", "blue", 12},
{"bright", "magenta", 13},
{"bright", "cyan", 14},
{"bright", "white", 15},
}
var result strings.Builder
currentSection := ""
for _, ac := range alacrittyColors {
if ac.section != currentSection {
if currentSection != "" {
result.WriteString("\n")
}
fmt.Fprintf(&result, "[colors.%s]\n", ac.section)
currentSection = ac.section
}
fmt.Fprintf(&result, "%-7s = '%s'\n", ac.name, colors[ac.index])
}
return result.String()
}
func GenerateGhosttyTheme(colors []string) string {
var result strings.Builder
for i, color := range colors {
fmt.Fprintf(&result, "palette = %d=%s\n", i, color)
}
return result.String()
}

View File

@@ -0,0 +1,250 @@
package dank16
import (
"encoding/json"
"fmt"
)
type VSCodeTheme struct {
Schema string `json:"$schema"`
Name string `json:"name"`
Type string `json:"type"`
Colors map[string]string `json:"colors"`
TokenColors []VSCodeTokenColor `json:"tokenColors"`
SemanticHighlighting bool `json:"semanticHighlighting"`
SemanticTokenColors map[string]VSCodeTokenSetting `json:"semanticTokenColors"`
}
type VSCodeTokenColor struct {
Scope interface{} `json:"scope"`
Settings VSCodeTokenSetting `json:"settings"`
}
type VSCodeTokenSetting struct {
Foreground string `json:"foreground,omitempty"`
FontStyle string `json:"fontStyle,omitempty"`
}
func updateTokenColor(tc interface{}, scopeToColor map[string]string) {
tcMap, ok := tc.(map[string]interface{})
if !ok {
return
}
scopes, ok := tcMap["scope"].([]interface{})
if !ok {
return
}
settings, ok := tcMap["settings"].(map[string]interface{})
if !ok {
return
}
isYaml := hasScopeContaining(scopes, "yaml")
for _, scope := range scopes {
scopeStr, ok := scope.(string)
if !ok {
continue
}
if scopeStr == "string" && isYaml {
continue
}
if applyColorToScope(settings, scope, scopeToColor) {
break
}
}
}
func applyColorToScope(settings map[string]interface{}, scope interface{}, scopeToColor map[string]string) bool {
scopeStr, ok := scope.(string)
if !ok {
return false
}
newColor, exists := scopeToColor[scopeStr]
if !exists {
return false
}
settings["foreground"] = newColor
return true
}
func hasScopeContaining(scopes []interface{}, substring string) bool {
for _, scope := range scopes {
scopeStr, ok := scope.(string)
if !ok {
continue
}
for i := 0; i <= len(scopeStr)-len(substring); i++ {
if scopeStr[i:i+len(substring)] == substring {
return true
}
}
}
return false
}
func EnrichVSCodeTheme(themeData []byte, colors []string) ([]byte, error) {
var theme map[string]interface{}
if err := json.Unmarshal(themeData, &theme); err != nil {
return nil, err
}
colorsMap, ok := theme["colors"].(map[string]interface{})
if !ok {
colorsMap = make(map[string]interface{})
theme["colors"] = colorsMap
}
bg := colors[0]
isLight := false
if len(bg) == 7 && bg[0] == '#' {
r, g, b := 0, 0, 0
fmt.Sscanf(bg[1:], "%02x%02x%02x", &r, &g, &b)
luminance := (0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)) / 255.0
isLight = luminance > 0.5
}
if isLight {
theme["type"] = "light"
} else {
theme["type"] = "dark"
}
colorsMap["terminal.ansiBlack"] = colors[0]
colorsMap["terminal.ansiRed"] = colors[1]
colorsMap["terminal.ansiGreen"] = colors[2]
colorsMap["terminal.ansiYellow"] = colors[3]
colorsMap["terminal.ansiBlue"] = colors[4]
colorsMap["terminal.ansiMagenta"] = colors[5]
colorsMap["terminal.ansiCyan"] = colors[6]
colorsMap["terminal.ansiWhite"] = colors[7]
colorsMap["terminal.ansiBrightBlack"] = colors[8]
colorsMap["terminal.ansiBrightRed"] = colors[9]
colorsMap["terminal.ansiBrightGreen"] = colors[10]
colorsMap["terminal.ansiBrightYellow"] = colors[11]
colorsMap["terminal.ansiBrightBlue"] = colors[12]
colorsMap["terminal.ansiBrightMagenta"] = colors[13]
colorsMap["terminal.ansiBrightCyan"] = colors[14]
colorsMap["terminal.ansiBrightWhite"] = colors[15]
tokenColors, ok := theme["tokenColors"].([]interface{})
if ok {
scopeToColor := map[string]string{
"comment": colors[8],
"punctuation.definition.comment": colors[8],
"keyword": colors[5],
"storage.type": colors[13],
"storage.modifier": colors[5],
"variable": colors[15],
"variable.parameter": colors[7],
"meta.object-literal.key": colors[4],
"meta.property.object": colors[4],
"variable.other.property": colors[4],
"constant.other.symbol": colors[12],
"constant.numeric": colors[12],
"constant.language": colors[12],
"constant.character": colors[3],
"entity.name.type": colors[12],
"support.type": colors[13],
"entity.name.class": colors[12],
"entity.name.function": colors[2],
"support.function": colors[2],
"support.class": colors[15],
"support.variable": colors[15],
"variable.language": colors[12],
"entity.name.tag.yaml": colors[12],
"string.unquoted.plain.out.yaml": colors[15],
"string.unquoted.yaml": colors[15],
"string": colors[3],
}
for i, tc := range tokenColors {
updateTokenColor(tc, scopeToColor)
tokenColors[i] = tc
}
yamlRules := []VSCodeTokenColor{
{
Scope: "entity.name.tag.yaml",
Settings: VSCodeTokenSetting{Foreground: colors[12]},
},
{
Scope: []string{"string.unquoted.plain.out.yaml", "string.unquoted.yaml"},
Settings: VSCodeTokenSetting{Foreground: colors[15]},
},
}
for _, rule := range yamlRules {
tokenColors = append(tokenColors, rule)
}
theme["tokenColors"] = tokenColors
}
if semanticTokenColors, ok := theme["semanticTokenColors"].(map[string]interface{}); ok {
updates := map[string]string{
"variable": colors[15],
"variable.readonly": colors[12],
"property": colors[4],
"function": colors[2],
"method": colors[2],
"type": colors[12],
"class": colors[12],
"typeParameter": colors[13],
"enumMember": colors[12],
"string": colors[3],
"number": colors[12],
"comment": colors[8],
"keyword": colors[5],
"operator": colors[15],
"parameter": colors[7],
"namespace": colors[15],
}
for key, color := range updates {
if existing, ok := semanticTokenColors[key].(map[string]interface{}); ok {
existing["foreground"] = color
} else {
semanticTokenColors[key] = map[string]interface{}{
"foreground": color,
}
}
}
} else {
semanticTokenColors := make(map[string]interface{})
updates := map[string]string{
"variable": colors[7],
"variable.readonly": colors[12],
"property": colors[4],
"function": colors[2],
"method": colors[2],
"type": colors[12],
"class": colors[12],
"typeParameter": colors[13],
"enumMember": colors[12],
"string": colors[3],
"number": colors[12],
"comment": colors[8],
"keyword": colors[5],
"operator": colors[15],
"parameter": colors[7],
"namespace": colors[15],
}
for key, color := range updates {
semanticTokenColors[key] = map[string]interface{}{
"foreground": color,
}
}
theme["semanticTokenColors"] = semanticTokenColors
}
return json.MarshalIndent(theme, "", " ")
}

View File

@@ -0,0 +1,51 @@
package deps
import (
"context"
)
type DependencyStatus int
const (
StatusMissing DependencyStatus = iota
StatusInstalled
StatusNeedsUpdate
StatusNeedsReinstall
)
type PackageVariant int
const (
VariantStable PackageVariant = iota
VariantGit
)
type Dependency struct {
Name string
Status DependencyStatus
Version string
Description string
Required bool
Variant PackageVariant
CanToggle bool
}
type WindowManager int
const (
WindowManagerHyprland WindowManager = iota
WindowManagerNiri
)
type Terminal int
const (
TerminalGhostty Terminal = iota
TerminalKitty
TerminalAlacritty
)
type DependencyDetector interface {
DetectDependencies(ctx context.Context, wm WindowManager) ([]Dependency, error)
DetectDependenciesWithTerminal(ctx context.Context, wm WindowManager, terminal Terminal) ([]Dependency, error)
}

View File

@@ -0,0 +1,786 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
func init() {
Register("arch", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("archarm", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("archcraft", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("endeavouros", "#7F3FBF", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("manjaro", "#35BF5C", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("obarun", "#2494be", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
Register("garuda", "#cba6f7", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
}
type ArchDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewArchDistribution(config DistroConfig, logChan chan<- string) *ArchDistribution {
base := NewBaseDistribution(logChan)
return &ArchDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (a *ArchDistribution) GetID() string {
return a.config.ID
}
func (a *ArchDistribution) GetColorHex() string {
return a.config.ColorHex
}
func (a *ArchDistribution) GetFamily() DistroFamily {
return a.config.Family
}
func (a *ArchDistribution) GetPackageManager() PackageManagerType {
return PackageManagerPacman
}
func (a *ArchDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return a.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, a.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, a.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, a.detectGit())
dependencies = append(dependencies, a.detectWindowManager(wm))
dependencies = append(dependencies, a.detectQuickshell())
dependencies = append(dependencies, a.detectXDGPortal())
dependencies = append(dependencies, a.detectPolkitAgent())
dependencies = append(dependencies, a.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, a.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
dependencies = append(dependencies, a.detectHyprpicker())
dependencies = append(dependencies, a.detectClipboardTools()...)
return dependencies, nil
}
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (a *ArchDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (a *ArchDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if a.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (a *ArchDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("pacman", "-Q", pkg)
err := cmd.Run()
return err == nil
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
"git": {Name: "git", Repository: RepoTypeSystem},
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
"matugen": a.getMatugenMapping(variants["matugen"]),
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeSystem},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = a.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = a.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprland-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "niri-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
if runtime.GOARCH == "arm64" {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
}
if variant == deps.VariantGit {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
if forceDMSGit || variant == deps.VariantGit {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
}
if a.packageInstalled("dms-shell-git") {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
}
if a.packageInstalled("dms-shell-bin") {
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if a.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking base-devel...",
IsComplete: false,
LogOutput: "Checking if base-devel is installed",
}
checkCmd := exec.CommandContext(ctx, "pacman", "-Qq", "base-devel")
if err := checkCmd.Run(); err == nil {
a.log("base-devel already installed")
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "base-devel already installed",
IsComplete: false,
LogOutput: "base-devel is already installed on the system",
}
return nil
}
a.log("Installing base-devel...")
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: "Installing base-devel...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo pacman -S --needed --noconfirm base-devel",
LogOutput: "Installing base-devel development tools",
}
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
return fmt.Errorf("failed to install base-devel: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.12,
Step: "base-devel installation complete",
IsComplete: false,
LogOutput: "base-devel successfully installed",
}
return nil
}
func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := a.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, aurPkgs, manualPkgs := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 3: System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := a.installSystemPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install system packages: %w", err)
}
}
// Phase 4: AUR Packages
if len(aurPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d AUR packages...", len(aurPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing AUR packages: %s", strings.Join(aurPkgs, ", ")),
}
if err := a.installAURPackages(ctx, aurPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install AUR packages: %w", err)
}
}
// Phase 5: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := a.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 6: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string, []string) {
systemPkgs := []string{}
aurPkgs := []string{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := a.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
// If no mapping exists, treat as manual build
manualPkgs = append(manualPkgs, dep.Name)
continue
}
switch pkgInfo.Repository {
case RepoTypeAUR:
aurPkgs = append(aurPkgs, pkgInfo.Name)
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, aurPkgs, manualPkgs
}
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", ")))
hasNiri := false
hasQuickshell := false
for _, pkg := range packages {
if pkg == "niri-git" {
hasNiri = true
}
if pkg == "quickshell" || pkg == "quickshell-git" {
hasQuickshell = true
}
}
// If quickshell is in the list, always reinstall google-breakpad first
if hasQuickshell {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.63,
Step: "Reinstalling google-breakpad for quickshell...",
IsComplete: false,
CommandInfo: "Reinstalling prerequisite AUR package for quickshell",
}
if err := a.installSingleAURPackage(ctx, "google-breakpad", sudoPassword, progressChan, 0.63, 0.65); err != nil {
return fmt.Errorf("failed to reinstall google-breakpad prerequisite for quickshell: %w", err)
}
}
// If niri is in the list, install makepkg-git-lfs-proto first if not already installed
if hasNiri {
if !a.packageInstalled("makepkg-git-lfs-proto") {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: "Installing makepkg-git-lfs-proto for niri...",
IsComplete: false,
CommandInfo: "Installing prerequisite for niri-git",
}
if err := a.installSingleAURPackage(ctx, "makepkg-git-lfs-proto", sudoPassword, progressChan, 0.65, 0.67); err != nil {
return fmt.Errorf("failed to install makepkg-git-lfs-proto prerequisite for niri: %w", err)
}
}
}
// Reorder packages to ensure dms-shell-git dependencies are installed first
orderedPackages := a.reorderAURPackages(packages)
baseProgress := 0.67
progressStep := 0.13 / float64(len(orderedPackages))
for i, pkg := range orderedPackages {
currentProgress := baseProgress + (float64(i) * progressStep)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: currentProgress,
Step: fmt.Sprintf("Installing AUR package %s (%d/%d)...", pkg, i+1, len(packages)),
IsComplete: false,
CommandInfo: fmt.Sprintf("Building and installing %s", pkg),
}
if err := a.installSingleAURPackage(ctx, pkg, sudoPassword, progressChan, currentProgress, currentProgress+progressStep); err != nil {
return fmt.Errorf("failed to install AUR package %s: %w", pkg, err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.80,
Step: "All AUR packages installed successfully",
IsComplete: false,
LogOutput: fmt.Sprintf("Successfully installed AUR packages: %s", strings.Join(packages, ", ")),
}
return nil
}
func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
dmsDepencies := []string{"quickshell", "quickshell-git", "dgop"}
var deps []string
var others []string
var dmsShell []string
for _, pkg := range packages {
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
dmsShell = append(dmsShell, pkg)
} else {
isDep := false
for _, dep := range dmsDepencies {
if pkg == dep {
deps = append(deps, pkg)
isDep = true
break
}
}
if !isDep {
others = append(others, pkg)
}
}
}
result := append(deps, others...)
result = append(result, dmsShell...)
return result
}
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "aur-builds", pkg)
// Clean up any existing cache first
if err := os.RemoveAll(buildDir); err != nil {
a.log(fmt.Sprintf("Warning: failed to clean existing cache for %s: %v", pkg, err))
}
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
defer func() {
if removeErr := os.RemoveAll(buildDir); removeErr != nil {
a.log(fmt.Sprintf("Warning: failed to cleanup build directory %s: %v", buildDir, removeErr))
}
}()
// Clone the AUR package
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.1*(endProgress-startProgress),
Step: fmt.Sprintf("Cloning %s from AUR...", pkg),
IsComplete: false,
CommandInfo: fmt.Sprintf("git clone https://aur.archlinux.org/%s.git", pkg),
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("https://aur.archlinux.org/%s.git", pkg), filepath.Join(buildDir, pkg))
if err := a.runWithProgress(cloneCmd, progressChan, PhaseAURPackages, startProgress+0.1*(endProgress-startProgress), startProgress+0.2*(endProgress-startProgress)); err != nil {
return fmt.Errorf("failed to clone %s: %w", pkg, err)
}
packageDir := filepath.Join(buildDir, pkg)
if pkg == "niri-git" {
pkgbuildPath := filepath.Join(packageDir, "PKGBUILD")
sedCmd := exec.CommandContext(ctx, "sed", "-i", "s/makepkg-git-lfs-proto//g", pkgbuildPath)
if err := sedCmd.Run(); err != nil {
return fmt.Errorf("failed to patch PKGBUILD for niri-git: %w", err)
}
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
sedCmd2 := exec.CommandContext(ctx, "sed", "-i", "/makedepends = makepkg-git-lfs-proto/d", srcinfoPath)
if err := sedCmd2.Run(); err != nil {
return fmt.Errorf("failed to patch .SRCINFO for niri-git: %w", err)
}
}
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
depsToRemove := []string{
"depends = quickshell",
"depends = dgop",
}
for _, dep := range depsToRemove {
sedCmd := exec.CommandContext(ctx, "sed", "-i", fmt.Sprintf("/%s/d", dep), srcinfoPath)
if err := sedCmd.Run(); err != nil {
return fmt.Errorf("failed to remove dependency %s from .SRCINFO for %s: %w", dep, pkg, err)
}
}
}
// Remove all optdepends from .SRCINFO for all packages
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
optdepsCmd := exec.CommandContext(ctx, "sed", "-i", "/^[[:space:]]*optdepends = /d", srcinfoPath)
if err := optdepsCmd.Run(); err != nil {
return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err)
}
// Skip dependency installation for dms-shell-git and dms-shell-bin
// since we manually manage those dependencies
if pkg != "dms-shell-git" && pkg != "dms-shell-bin" {
// Pre-install dependencies from .SRCINFO
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.3*(endProgress-startProgress),
Step: fmt.Sprintf("Installing dependencies for %s...", pkg),
IsComplete: false,
CommandInfo: "Installing package dependencies and makedepends",
}
// Install dependencies and makedepends explicitly
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
depsCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`
deps=$(grep "depends = " "%s" | grep -v "makedepends" | sed 's/.*depends = //' | tr '\n' ' ' | sed 's/[[:space:]]*$//')
if [[ "%s" == *"quickshell"* ]]; then
deps=$(echo "$deps" | sed 's/google-breakpad//g' | sed 's/ / /g' | sed 's/^ *//g' | sed 's/ *$//g')
fi
if [ ! -z "$deps" ] && [ "$deps" != " " ]; then
echo '%s' | sudo -S pacman -S --needed --noconfirm $deps
fi
`, srcinfoPath, pkg, sudoPassword))
if err := a.runWithProgress(depsCmd, progressChan, PhaseAURPackages, startProgress+0.3*(endProgress-startProgress), startProgress+0.35*(endProgress-startProgress)); err != nil {
return fmt.Errorf("FAILED to install runtime dependencies for %s: %w", pkg, err)
}
makedepsCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf(`
makedeps=$(grep -E "^[[:space:]]*makedepends = " "%s" | sed 's/^[[:space:]]*makedepends = //' | tr '\n' ' ')
if [ ! -z "$makedeps" ]; then
echo '%s' | sudo -S pacman -S --needed --noconfirm $makedeps
fi
`, srcinfoPath, sudoPassword))
if err := a.runWithProgress(makedepsCmd, progressChan, PhaseAURPackages, startProgress+0.35*(endProgress-startProgress), startProgress+0.4*(endProgress-startProgress)); err != nil {
return fmt.Errorf("FAILED to install make dependencies for %s: %w", pkg, err)
}
} else {
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),
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.4*(endProgress-startProgress),
Step: fmt.Sprintf("Building %s...", pkg),
IsComplete: false,
CommandInfo: "makepkg --noconfirm",
}
buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm")
buildCmd.Dir = packageDir
buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar") // Disable compression for speed
if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil {
return fmt.Errorf("failed to build %s: %w", pkg, err)
}
// Find built package file
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.7*(endProgress-startProgress),
Step: fmt.Sprintf("Installing %s...", pkg),
IsComplete: false,
CommandInfo: "sudo pacman -U built-package",
}
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
var files []string
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
// For DMS split packages, install base package
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
matches, err := filepath.Glob(pattern)
if err == nil {
for _, match := range matches {
basename := filepath.Base(match)
// Always include base package
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
files = append(files, match)
}
}
}
// Also update compositor-specific packages if they're installed
if strings.HasSuffix(pkg, "-git") {
if a.packageInstalled("dms-shell-hyprland-git") {
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
files = append(files, hyprlandMatches[0])
}
}
if a.packageInstalled("dms-shell-niri-git") {
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
files = append(files, niriMatches[0])
}
}
}
} else {
// For other packages, install all built packages
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
files = matches
}
if len(files) == 0 {
return fmt.Errorf("no package files found after building %s", pkg)
}
installArgs := []string{"pacman", "-U", "--noconfirm"}
installArgs = append(installArgs, files...)
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
fileNames := make([]string, len(files))
for i, f := range files {
fileNames[i] = filepath.Base(f)
}
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress + 0.7*(endProgress-startProgress),
LogOutput: fmt.Sprintf("Installing packages: %s", strings.Join(fileNames, ", ")),
}
if err := a.runWithProgress(installCmd, progressChan, PhaseAURPackages, startProgress+0.7*(endProgress-startProgress), endProgress); err != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: startProgress,
LogOutput: fmt.Sprintf("ERROR: pacman -U failed for %s with error: %v", pkg, err),
Error: err,
}
return fmt.Errorf("failed to install built package %s: %w", pkg, err)
}
a.log(fmt.Sprintf("Successfully installed AUR package: %s", pkg))
return nil
}

View File

@@ -0,0 +1,659 @@
package distros
import (
"bufio"
"context"
_ "embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/version"
)
const forceQuickshellGit = false
const forceDMSGit = false
// BaseDistribution provides common functionality for all distributions
type BaseDistribution struct {
logChan chan<- string
}
// NewBaseDistribution creates a new base distribution
func NewBaseDistribution(logChan chan<- string) *BaseDistribution {
return &BaseDistribution{
logChan: logChan,
}
}
// Common helper methods
func (b *BaseDistribution) commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func (b *BaseDistribution) CommandExists(cmd string) bool {
return b.commandExists(cmd)
}
func (b *BaseDistribution) log(message string) {
if b.logChan != nil {
b.logChan <- message
}
}
func (b *BaseDistribution) logError(message string, err error) {
errorMsg := fmt.Sprintf("ERROR: %s: %v", message, err)
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)
}
// Common dependency detection methods
func (b *BaseDistribution) detectGit() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("git") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "git",
Status: status,
Description: "Version control system",
Required: true,
}
}
func (b *BaseDistribution) detectMatugen() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("matugen") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "matugen",
Status: status,
Description: "Material Design color generation tool",
Required: true,
}
}
func (b *BaseDistribution) detectDgop() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("dgop") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dgop",
Status: status,
Description: "Desktop portal management tool",
Required: true,
}
}
func (b *BaseDistribution) detectDMS() deps.Dependency {
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
status := deps.StatusMissing
currentVersion := ""
if _, err := os.Stat(dmsPath); err == nil {
status = deps.StatusInstalled
// Only get current version, don't check for updates (lazy loading)
current, err := version.GetCurrentDMSVersion()
if err == nil {
currentVersion = current
}
}
dep := deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Description: "Desktop Management System configuration",
Required: true,
CanToggle: true,
}
if currentVersion != "" {
dep.Version = currentVersion
}
return dep
}
func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.Dependency {
switch terminal {
case deps.TerminalGhostty:
status := deps.StatusMissing
if b.commandExists("ghostty") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "ghostty",
Status: status,
Description: "A fast, native terminal emulator built in Zig.",
Required: true,
}
case deps.TerminalKitty:
status := deps.StatusMissing
if b.commandExists("kitty") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "kitty",
Status: status,
Description: "A feature-rich, customizable terminal emulator.",
Required: true,
}
case deps.TerminalAlacritty:
status := deps.StatusMissing
if b.commandExists("alacritty") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "alacritty",
Status: status,
Description: "A simple terminal emulator. (No dynamic theming)",
Required: true,
}
default:
return b.detectSpecificTerminal(deps.TerminalGhostty)
}
}
func (b *BaseDistribution) detectClipboardTools() []deps.Dependency {
var dependencies []deps.Dependency
cliphist := deps.StatusMissing
if b.commandExists("cliphist") {
cliphist = deps.StatusInstalled
}
wlClipboard := deps.StatusMissing
if b.commandExists("wl-copy") && b.commandExists("wl-paste") {
wlClipboard = deps.StatusInstalled
}
dependencies = append(dependencies,
deps.Dependency{
Name: "cliphist",
Status: cliphist,
Description: "Wayland clipboard manager",
Required: true,
},
deps.Dependency{
Name: "wl-clipboard",
Status: wlClipboard,
Description: "Wayland clipboard utilities",
Required: true,
},
)
return dependencies
}
func (b *BaseDistribution) detectHyprpicker() deps.Dependency {
status := deps.StatusMissing
if b.commandExists("hyprpicker") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "hyprpicker",
Status: status,
Description: "Color picker for Wayland",
Required: true,
}
}
func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency {
var dependencies []deps.Dependency
tools := []struct {
name string
description string
}{
{"grim", "Screenshot utility for Wayland"},
{"slurp", "Region selection utility for Wayland"},
{"hyprctl", "Hyprland control utility"},
{"grimblast", "Screenshot script for Hyprland"},
{"jq", "JSON processor"},
}
for _, tool := range tools {
status := deps.StatusMissing
if b.commandExists(tool.name) {
status = deps.StatusInstalled
}
dependencies = append(dependencies, deps.Dependency{
Name: tool.name,
Status: status,
Description: tool.description,
Required: true,
})
}
return dependencies
}
func (b *BaseDistribution) detectQuickshell() deps.Dependency {
if !b.commandExists("qs") {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusMissing,
Description: "QtQuick based desktop shell toolkit",
Required: true,
Variant: deps.VariantStable,
CanToggle: true,
}
}
cmd := exec.Command("qs", "--version")
output, err := cmd.Output()
if err != nil {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusNeedsReinstall,
Description: "QtQuick based desktop shell toolkit (version check failed)",
Required: true,
Variant: deps.VariantStable,
CanToggle: true,
}
}
versionStr := string(output)
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
matches := versionRegex.FindStringSubmatch(versionStr)
if len(matches) < 2 {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusNeedsReinstall,
Description: "QtQuick based desktop shell toolkit (unknown version)",
Required: true,
Variant: deps.VariantStable,
CanToggle: true,
}
}
version := matches[1]
variant := deps.VariantStable
if strings.Contains(versionStr, "git") || strings.Contains(versionStr, "+") {
variant = deps.VariantGit
}
if b.versionCompare(version, "0.2.0") >= 0 {
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusInstalled,
Version: version,
Description: "QtQuick based desktop shell toolkit",
Required: true,
Variant: variant,
CanToggle: true,
}
}
return deps.Dependency{
Name: "quickshell",
Status: deps.StatusNeedsUpdate,
Variant: variant,
CanToggle: true,
Version: version,
Description: "QtQuick based desktop shell toolkit (needs 0.2.0+)",
Required: true,
}
}
func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency {
switch wm {
case deps.WindowManagerHyprland:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("hyprland") || b.commandExists("Hyprland") {
status = deps.StatusInstalled
cmd := exec.Command("hyprctl", "version")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`v(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "hyprland",
Status: status,
Version: version,
Description: "Dynamic tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
case deps.WindowManagerNiri:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("niri") {
status = deps.StatusInstalled
cmd := exec.Command("niri", "--version")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "+") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`niri (\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "niri",
Status: status,
Version: version,
Description: "Scrollable-tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
Status: deps.StatusMissing,
Description: "Unknown window manager",
Required: true,
}
}
}
// Version comparison helper
func (b *BaseDistribution) versionCompare(v1, v2 string) int {
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
for i := 0; i < len(parts1) && i < len(parts2); i++ {
if parts1[i] < parts2[i] {
return -1
}
if parts1[i] > parts2[i] {
return 1
}
}
if len(parts1) < len(parts2) {
return -1
}
if len(parts1) > len(parts2) {
return 1
}
return 0
}
// Common installation helper
func (b *BaseDistribution) runWithProgress(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64) error {
return b.runWithProgressTimeout(cmd, progressChan, phase, startProgress, endProgress, 20*time.Minute)
}
func (b *BaseDistribution) runWithProgressTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, timeout time.Duration) error {
return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, "Installing...", timeout)
}
func (b *BaseDistribution) runWithProgressStep(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string) error {
return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, stepMessage, 20*time.Minute)
}
func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string, timeoutDuration time.Duration) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return err
}
outputChan := make(chan string, 100)
done := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
b.log(line)
outputChan <- line
}
}()
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
line := scanner.Text()
b.log(line)
outputChan <- line
}
}()
go func() {
done <- cmd.Wait()
close(outputChan)
}()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
progress := startProgress
progressStep := (endProgress - startProgress) / 50
lastOutput := ""
var timeout *time.Timer
var timeoutChan <-chan time.Time
if timeoutDuration > 0 {
timeout = time.NewTimer(timeoutDuration)
defer timeout.Stop()
timeoutChan = timeout.C
}
for {
select {
case err := <-done:
if err != nil {
b.logError("Command execution failed", err)
b.log(fmt.Sprintf("Last output before failure: %s", lastOutput))
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: startProgress,
Step: "Command failed",
IsComplete: false,
LogOutput: lastOutput,
Error: err,
}
return err
}
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: endProgress,
Step: "Installation step complete",
IsComplete: false,
LogOutput: lastOutput,
}
return nil
case output, ok := <-outputChan:
if ok {
lastOutput = output
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: progress,
Step: stepMessage,
IsComplete: false,
LogOutput: output,
}
if timeout != nil {
timeout.Reset(timeoutDuration)
}
}
case <-timeoutChan:
if cmd.Process != nil {
cmd.Process.Kill()
}
err := fmt.Errorf("installation timed out after %v", timeoutDuration)
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: startProgress,
Step: "Installation timed out",
IsComplete: false,
LogOutput: lastOutput,
Error: err,
}
return err
case <-ticker.C:
if progress < endProgress-0.01 {
progress += progressStep
progressChan <- InstallProgressMsg{
Phase: phase,
Progress: progress,
Step: "Installing...",
IsComplete: false,
LogOutput: lastOutput,
}
}
}
}
}
// installDMSBinary installs the DMS binary from GitHub releases
func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
b.log("Installing/updating DMS binary...")
// Detect architecture
arch := runtime.GOARCH
switch arch {
case "amd64":
case "arm64":
default:
return fmt.Errorf("unsupported architecture for DMS: %s", arch)
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.80,
Step: "Downloading DMS binary...",
IsComplete: false,
CommandInfo: fmt.Sprintf("Downloading dms-%s.gz", arch),
}
// Get latest release version
latestVersionCmd := exec.CommandContext(ctx, "bash", "-c",
`curl -s https://api.github.com/repos/AvengeMedia/danklinux/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'`)
versionOutput, err := latestVersionCmd.Output()
if err != nil {
return fmt.Errorf("failed to get latest DMS version: %w", err)
}
version := strings.TrimSpace(string(versionOutput))
if version == "" {
return fmt.Errorf("could not determine latest DMS version")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
// Download the gzipped binary
downloadURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/backend/releases/download/%s/dms-%s.gz", version, arch)
gzPath := filepath.Join(tmpDir, "dms.gz")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", downloadURL, "-o", gzPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download DMS binary: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.85,
Step: "Extracting DMS binary...",
IsComplete: false,
CommandInfo: "gunzip dms.gz",
}
// Extract the binary
extractCmd := exec.CommandContext(ctx, "gunzip", gzPath)
if err := extractCmd.Run(); err != nil {
return fmt.Errorf("failed to extract DMS binary: %w", err)
}
binaryPath := filepath.Join(tmpDir, "dms")
// Make it executable
chmodCmd := exec.CommandContext(ctx, "chmod", "+x", binaryPath)
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make DMS binary executable: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.88,
Step: "Installing DMS to /usr/local/bin...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp dms /usr/local/bin/",
}
// Install to /usr/local/bin
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install DMS binary: %w", err)
}
b.log("DMS binary installed successfully")
return nil
}

View File

@@ -0,0 +1,220 @@
package distros
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
func TestBaseDistribution_detectDMS_NotInstalled(t *testing.T) {
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
tempDir := t.TempDir()
os.Setenv("HOME", tempDir)
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status != deps.StatusMissing {
t.Errorf("Expected StatusMissing, got %d", dep.Status)
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
}
func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
if !commandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
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"), 0644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status == deps.StatusMissing {
t.Error("Expected DMS to be detected as installed")
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
t.Logf("Status: %d, Version: %s", dep.Status, dep.Version)
}
func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
if !commandExists("git") {
t.Skip("git not available")
}
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
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, "remote", "add", "origin", "https://github.com/AvengeMedia/DankMaterialShell.git").Run()
testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0644)
exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
exec.Command("git", "-C", dmsPath, "tag", "v0.0.1").Run()
exec.Command("git", "-C", dmsPath, "checkout", "v0.0.1").Run()
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
t.Logf("Status: %d, Version: %s", dep.Status, dep.Version)
}
func TestBaseDistribution_detectDMS_DirectoryWithoutGit(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
dep := base.detectDMS()
if dep.Status == deps.StatusMissing {
t.Error("Expected DMS to be detected as present")
}
if dep.Name != "dms (DankMaterialShell)" {
t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name)
}
if !dep.Required {
t.Error("Expected Required to be true")
}
}
func TestBaseDistribution_NewBaseDistribution(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
if base == nil {
t.Fatal("NewBaseDistribution returned nil")
}
if base.logChan == nil {
t.Error("logChan was not set")
}
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func TestBaseDistribution_versionCompare(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
tests := []struct {
v1 string
v2 string
expected int
}{
{"0.1.0", "0.1.0", 0},
{"0.1.0", "0.1.1", -1},
{"0.1.1", "0.1.0", 1},
{"0.2.0", "0.1.9", 1},
{"1.0.0", "0.9.9", 1},
}
for _, tt := range tests {
result := base.versionCompare(tt.v1, tt.v2)
if result != tt.expected {
t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
}
}
}
func TestBaseDistribution_versionCompare_WithPrefix(t *testing.T) {
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
tests := []struct {
v1 string
v2 string
expected int
}{
{"v0.1.0", "v0.1.0", 0},
{"v0.1.0", "v0.1.1", -1},
{"v0.1.1", "v0.1.0", 1},
}
for _, tt := range tests {
result := base.versionCompare(tt.v1, tt.v2)
if result != tt.expected {
t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
}
}
}

View File

@@ -0,0 +1,537 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
func init() {
Register("debian", "#A80030", FamilyDebian, func(config DistroConfig, logChan chan<- string) Distribution {
return NewDebianDistribution(config, logChan)
})
}
type DebianDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewDebianDistribution(config DistroConfig, logChan chan<- string) *DebianDistribution {
base := NewBaseDistribution(logChan)
return &DebianDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (d *DebianDistribution) GetID() string {
return d.config.ID
}
func (d *DebianDistribution) GetColorHex() string {
return d.config.ColorHex
}
func (d *DebianDistribution) GetFamily() DistroFamily {
return d.config.Family
}
func (d *DebianDistribution) GetPackageManager() PackageManagerType {
return PackageManagerAPT
}
func (d *DebianDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return d.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, d.detectDMS())
dependencies = append(dependencies, d.detectSpecificTerminal(terminal))
dependencies = append(dependencies, d.detectGit())
dependencies = append(dependencies, d.detectWindowManager(wm))
dependencies = append(dependencies, d.detectQuickshell())
dependencies = append(dependencies, d.detectXDGPortal())
dependencies = append(dependencies, d.detectPolkitAgent())
dependencies = append(dependencies, d.detectAccountsService())
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, d.detectXwaylandSatellite())
}
dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDgop())
dependencies = append(dependencies, d.detectHyprpicker())
dependencies = append(dependencies, d.detectClipboardTools()...)
return dependencies, nil
}
func (d *DebianDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (d *DebianDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (d *DebianDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if d.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (d *DebianDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if d.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (d *DebianDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
}
func (d *DebianDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "git", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"niri": {Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"ghostty": {Name: "ghostty", Repository: RepoTypeManual, BuildFunc: "installGhostty"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"cliphist": {Name: "cliphist", Repository: RepoTypeManual, BuildFunc: "installCliphist"},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeManual, BuildFunc: "installHyprpicker"},
}
if wm == deps.WindowManagerNiri {
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeManual, BuildFunc: "installXwaylandSatellite"}
}
return packages
}
func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Updating package lists...",
IsComplete: false,
LogOutput: "Updating APT package lists",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: "Installing build-essential...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y build-essential",
LogOutput: "Installing build tools",
}
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.12,
Step: "Prerequisites installation complete",
IsComplete: false,
LogOutput: "Prerequisites successfully installed",
}
return nil
}
func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := d.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, manualPkgs := d.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := d.installAPTPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install APT packages: %w", err)
}
}
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.80,
Step: "Installing build dependencies...",
IsComplete: false,
LogOutput: "Installing build tools for manual compilation",
}
if err := d.installBuildDependencies(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install build dependencies: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := d.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (d *DebianDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string) {
systemPkgs := []string{}
manualPkgs := []string{}
packageMap := d.GetPackageMapping(wm)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
d.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, manualPkgs
}
func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
d.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
buildDeps := make(map[string]bool)
for _, pkg := range manualPkgs {
switch pkg {
case "niri":
buildDeps["curl"] = true
buildDeps["libxkbcommon-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["libudev-dev"] = true
buildDeps["libinput-dev"] = true
buildDeps["libdisplay-info-dev"] = true
buildDeps["libpango1.0-dev"] = true
buildDeps["libcairo-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libc6-dev"] = true
buildDeps["clang"] = true
buildDeps["libseat-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["alacritty"] = true
buildDeps["fuzzel"] = true
case "quickshell":
buildDeps["qt6-base-dev"] = true
buildDeps["qt6-base-private-dev"] = true
buildDeps["qt6-declarative-dev"] = true
buildDeps["qt6-declarative-private-dev"] = true
buildDeps["qt6-wayland-dev"] = true
buildDeps["qt6-wayland-private-dev"] = true
buildDeps["qt6-tools-dev"] = true
buildDeps["libqt6svg6-dev"] = true
buildDeps["qt6-shadertools-dev"] = true
buildDeps["spirv-tools"] = true
buildDeps["libcli11-dev"] = true
buildDeps["libjemalloc-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["wayland-protocols"] = true
buildDeps["libdrm-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["libegl-dev"] = true
buildDeps["libgles2-mesa-dev"] = true
buildDeps["libgl1-mesa-dev"] = true
buildDeps["libxcb1-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libpam0g-dev"] = true
case "ghostty":
buildDeps["curl"] = true
case "matugen":
buildDeps["curl"] = true
}
}
for _, pkg := range manualPkgs {
switch pkg {
case "niri", "matugen":
if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "cliphist", "dgop":
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
}
}
}
if len(buildDeps) == 0 {
return nil
}
depList := make([]string, 0, len(buildDeps))
for dep := range buildDeps {
depList = append(depList, dep)
}
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if d.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := d.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
if !d.commandExists("cargo") {
d.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if d.commandExists("go") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.87,
Step: "Installing Go...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}
func (d *DebianDistribution) installGhosttyDebian(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
d.log("Installing Ghostty using Debian installer script...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Running Ghostty Debian installer...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | sudo bash",
LogOutput: "Installing Ghostty using pre-built Debian package",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
"/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)\"")
if err := d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.9); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
d.log("Ghostty installed successfully using Debian installer")
return nil
}
func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
d.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
switch pkg {
case "ghostty":
if err := d.installGhosttyDebian(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
default:
if err := d.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

View File

@@ -0,0 +1,19 @@
package distros
import (
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
// NewDependencyDetector creates a DependencyDetector for the specified distribution
func NewDependencyDetector(distribution string, logChan chan<- string) (deps.DependencyDetector, error) {
distro, err := NewDistribution(distribution, logChan)
if err != nil {
return nil, err
}
return distro, nil
}
// NewPackageInstaller creates a Distribution for package installation
func NewPackageInstaller(distribution string, logChan chan<- string) (Distribution, error) {
return NewDistribution(distribution, logChan)
}

View File

@@ -0,0 +1,553 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
func init() {
Register("fedora", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("nobara", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
return NewFedoraDistribution(config, logChan)
})
}
type FedoraDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
base := NewBaseDistribution(logChan)
return &FedoraDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (f *FedoraDistribution) GetID() string {
return f.config.ID
}
func (f *FedoraDistribution) GetColorHex() string {
return f.config.ColorHex
}
func (f *FedoraDistribution) GetFamily() DistroFamily {
return f.config.Family
}
func (f *FedoraDistribution) GetPackageManager() PackageManagerType {
return PackageManagerDNF
}
func (f *FedoraDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return f.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, f.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, f.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, f.detectGit())
dependencies = append(dependencies, f.detectWindowManager(wm))
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectXDGPortal())
dependencies = append(dependencies, f.detectPolkitAgent())
dependencies = append(dependencies, f.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, f.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectHyprpicker())
dependencies = append(dependencies, f.detectClipboardTools()...)
return dependencies, nil
}
func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (f *FedoraDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
return err == nil
}
func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
// Standard DNF packages
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": f.getHyprpickerMapping(variants["hyprland"]),
// COPR packages
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"cliphist": {Name: "cliphist", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = f.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = f.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeCOPR, RepoURL: "yalter/niri"}
}
return packages
}
func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms-git"}
}
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms"}
}
func (f *FedoraDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprland-git", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
func (f *FedoraDistribution) getHyprpickerMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "hyprpicker-git", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
}
return PackageMapping{Name: "hyprpicker", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri-git"}
}
return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri"}
}
func (f *FedoraDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if f.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if f.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (f *FedoraDistribution) getPrerequisites() []string {
return []string{
"dnf-plugins-core",
"make",
"unzip",
"libwayland-server",
}
}
func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := f.getPrerequisites()
var missingPkgs []string
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
f.log("go not found in PATH, will install golang-bin")
missingPkgs = append(missingPkgs, "golang-bin")
} else {
f.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
f.log("All prerequisites already installed")
return nil
}
f.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo dnf install -y %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"dnf", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to install prerequisites", err)
f.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w", err)
}
f.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := f.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
dnfPkgs, coprPkgs, manualPkgs := f.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 2: Enable COPR repositories
if len(coprPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling COPR repositories...",
IsComplete: false,
LogOutput: "Setting up COPR repositories for additional packages",
}
if err := f.enableCOPRRepos(ctx, coprPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable COPR repositories: %w", err)
}
}
// Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(dnfPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(dnfPkgs, ", ")),
}
if err := f.installDNFPackages(ctx, dnfPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install DNF packages: %w", err)
}
}
// Phase 4: COPR Packages
coprPkgNames := f.extractPackageNames(coprPkgs)
if len(coprPkgNames) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, // Reusing AUR phase for COPR
Progress: 0.65,
Step: fmt.Sprintf("Installing %d COPR packages...", len(coprPkgNames)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing COPR packages: %s", strings.Join(coprPkgNames, ", ")),
}
if err := f.installCOPRPackages(ctx, coprPkgNames, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install COPR packages: %w", err)
}
}
// Phase 5: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := f.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 6: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (f *FedoraDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping, []string) {
dnfPkgs := []string{}
coprPkgs := []PackageMapping{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := f.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
f.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
dnfPkgs = append(dnfPkgs, pkgInfo.Name)
case RepoTypeCOPR:
coprPkgs = append(coprPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return dnfPkgs, coprPkgs, manualPkgs
}
func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
for _, pkg := range coprPkgs {
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
f.log(fmt.Sprintf("Enabling COPR repository: %s", pkg.RepoURL))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.20,
Step: fmt.Sprintf("Enabling COPR repo %s...", pkg.RepoURL),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
output, err := cmd.CombinedOutput()
if err != nil {
f.logError(fmt.Sprintf("failed to enable COPR repo %s", pkg.RepoURL), err)
f.log(fmt.Sprintf("COPR enable command output: %s", string(output)))
return fmt.Errorf("failed to enable COPR repo %s: %w", pkg.RepoURL, err)
}
f.log(fmt.Sprintf("COPR repo %s enabled successfully: %s", pkg.RepoURL, string(output)))
enabledRepos[pkg.RepoURL] = true
// Special handling for niri COPR repo - set priority=1
if pkg.RepoURL == "yalter/niri-git" {
f.log("Setting priority=1 for niri-git COPR repo...")
repoFile := "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.22,
Step: "Setting niri COPR repo priority...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
}
priorityCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s' 2>&1", repoFile))
priorityOutput, err := priorityCmd.CombinedOutput()
if err != nil {
f.logError("failed to set niri COPR repo priority", err)
f.log(fmt.Sprintf("Priority command output: %s", string(priorityOutput)))
return fmt.Errorf("failed to set niri COPR repo priority: %w", err)
}
f.log(fmt.Sprintf("niri COPR repo priority set successfully: %s", string(priorityOutput)))
}
}
}
return nil
}
func (f *FedoraDistribution) installDNFPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
f.log(fmt.Sprintf("Installing DNF packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
f.log(fmt.Sprintf("Installing COPR packages: %s", strings.Join(packages, ", ")))
args := []string{"dnf", "install", "-y"}
for _, pkg := range packages {
if pkg == "niri" || pkg == "niri-git" {
args = append(args, "--setopt=install_weak_deps=False")
break
}
}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing COPR packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return f.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
}

View File

@@ -0,0 +1,750 @@
package distros
import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
var GentooGlobalUseFlags = []string{
"dbus",
"udev",
"alsa",
"policykit",
"jpeg",
"png",
"webp",
"gif",
"tiff",
"svg",
"brotli",
"gdbm",
"accessibility",
"gtk",
"qt6",
"egl",
"gbm",
}
func init() {
Register("gentoo", "#54487A", FamilyGentoo, func(config DistroConfig, logChan chan<- string) Distribution {
return NewGentooDistribution(config, logChan)
})
}
type GentooDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
skipGlobalUseFlags bool
}
func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDistribution {
base := NewBaseDistribution(logChan)
return &GentooDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH
switch arch {
case "amd64":
return "~amd64"
case "arm64":
return "~arm64"
default:
return "~amd64"
}
}
func (g *GentooDistribution) GetID() string {
return g.config.ID
}
func (g *GentooDistribution) GetColorHex() string {
return g.config.ColorHex
}
func (g *GentooDistribution) GetFamily() DistroFamily {
return g.config.Family
}
func (g *GentooDistribution) GetPackageManager() PackageManagerType {
return PackageManagerPortage
}
func (g *GentooDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return g.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, g.detectDMS())
dependencies = append(dependencies, g.detectSpecificTerminal(terminal))
dependencies = append(dependencies, g.detectGit())
dependencies = append(dependencies, g.detectWindowManager(wm))
dependencies = append(dependencies, g.detectQuickshell())
dependencies = append(dependencies, g.detectXDGPortal())
dependencies = append(dependencies, g.detectPolkitAgent())
dependencies = append(dependencies, g.detectAccountsService())
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, g.detectHyprlandTools()...)
}
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectHyprpicker())
dependencies = append(dependencies, g.detectClipboardTools()...)
return dependencies, nil
}
func (g *GentooDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("sys-apps/xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (g *GentooDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("mate-extra/mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("gui-apps/xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (g *GentooDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if g.packageInstalled("sys-apps/accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (g *GentooDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("qlist", "-I", pkg)
err := cmd.Run()
return err == nil
}
func (g *GentooDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return g.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
archKeyword := g.getArchKeyword()
packages := map[string]PackageMapping{
"git": {Name: "dev-vcs/git", Repository: RepoTypeSystem},
"kitty": {Name: "x11-terms/kitty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
"alacritty": {Name: "x11-terms/alacritty", Repository: RepoTypeSystem, UseFlags: "X wayland"},
"wl-clipboard": {Name: "gui-apps/wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "sys-apps/xdg-desktop-portal-gtk", Repository: RepoTypeSystem, UseFlags: "wayland X"},
"mate-polkit": {Name: "mate-extra/mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "sys-apps/accountsservice", Repository: RepoTypeSystem},
"hyprpicker": g.getHyprpickerMapping(variants["hyprland"]),
"qtbase": {Name: "dev-qt/qtbase", Repository: RepoTypeSystem, UseFlags: "wayland opengl vulkan widgets"},
"qtdeclarative": {Name: "dev-qt/qtdeclarative", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"},
"qtwayland": {Name: "dev-qt/qtwayland", Repository: RepoTypeSystem},
"mesa": {Name: "media-libs/mesa", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"},
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"cliphist": {Name: "app-misc/cliphist", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = g.getHyprlandMapping(variants["hyprland"])
packages["grim"] = PackageMapping{Name: "gui-apps/grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "gui-apps/slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = g.getHyprlandMapping(variants["hyprland"])
packages["grimblast"] = PackageMapping{Name: "gui-wm/hyprland-contrib", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["jq"] = PackageMapping{Name: "app-misc/jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
}
return packages
}
func (g *GentooDistribution) getQuickshellMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
}
func (g *GentooDistribution) getDmsMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"}
}
func (g *GentooDistribution) getHyprlandMapping(variant deps.PackageVariant) PackageMapping {
archKeyword := g.getArchKeyword()
if variant == deps.VariantGit {
return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeGURU, UseFlags: "X", AcceptKeywords: archKeyword}
}
return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeSystem, UseFlags: "X", AcceptKeywords: archKeyword}
}
func (g *GentooDistribution) getHyprpickerMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-apps/hyprpicker", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string {
return []string{
"app-eselect/eselect-repository",
"dev-vcs/git",
"dev-build/make",
"app-arch/unzip",
"dev-util/pkgconf",
"dev-qt/qtdeclarative",
}
}
func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword string) error {
useFlags := strings.Join(GentooGlobalUseFlags, " ")
checkCmd := exec.CommandContext(ctx, "grep", "-q", "^USE=", "/etc/portage/make.conf")
hasUse := checkCmd.Run() == nil
var cmd *exec.Cmd
if hasUse {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf; exit_code=$?; exit $exit_code", useFlags))
} else {
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"; exit_code=$?; exit $exit_code", useFlags))
}
output, err := cmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("Failed to set global USE flags: %s", string(output)))
return err
}
g.log(fmt.Sprintf("Set global USE flags: %s", useFlags))
return nil
}
func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := g.getPrerequisites()
var missingPkgs []string
if !g.skipGlobalUseFlags {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Setting global USE flags...",
IsComplete: false,
LogOutput: "Configuring global USE flags in /etc/portage/make.conf",
}
if err := g.setGlobalUseFlags(ctx, sudoPassword); err != nil {
g.logError("failed to set global USE flags", err)
return fmt.Errorf("failed to set global USE flags: %w", err)
}
} else {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Skipping global USE flags...",
IsComplete: false,
LogOutput: "Skipping global USE flags configuration (using existing configuration)",
}
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "qlist", "-I", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
g.log("go not found in PATH, will install dev-lang/go")
missingPkgs = append(missingPkgs, "dev-lang/go")
} else {
g.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
g.log("All prerequisites already installed")
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.07,
Step: "Syncing Portage tree...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo emerge --sync",
LogOutput: "Syncing Portage tree with emerge --sync",
}
syncCmd := ExecSudoCommand(ctx, sudoPassword,
"emerge --sync --quiet; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput()
if syncErr != nil {
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
return fmt.Errorf("failed to sync Portage tree: %w\nOutput: %s", syncErr, string(syncOutput))
}
g.log("Portage tree synced successfully")
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("%s; exit_code=$?; exit $exit_code", strings.Join(args, " ")))
output, err := cmd.CombinedOutput()
if err != nil {
g.logError("failed to install prerequisites", err)
g.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w\nOutput: %s", err, string(output))
}
g.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (g *GentooDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
g.skipGlobalUseFlags = skipGlobalUseFlags
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := g.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, guruPkgs, manualPkgs := g.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
g.log(fmt.Sprintf("CATEGORIZED PACKAGES: system=%d, guru=%d, manual=%d", len(systemPkgs), len(guruPkgs), len(manualPkgs)))
if len(systemPkgs) > 0 {
systemPkgNames := g.extractPackageNames(systemPkgs)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgNames, ", ")),
}
if err := g.installPortagePackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Portage packages: %w", err)
}
}
if len(guruPkgs) > 0 {
g.log(fmt.Sprintf("FOUND %d GURU PACKAGES - WILL SYNC GURU REPO", len(guruPkgs)))
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.60,
Step: "Syncing GURU repository...",
IsComplete: false,
LogOutput: "Syncing GURU repository to fetch latest ebuilds",
}
g.log("ABOUT TO CALL syncGURURepo")
if err := g.syncGURURepo(ctx, sudoPassword, progressChan); err != nil {
g.log(fmt.Sprintf("syncGURURepo RETURNED ERROR: %v", err))
return fmt.Errorf("failed to sync GURU repository: %w", err)
}
g.log("syncGURURepo COMPLETED SUCCESSFULLY")
guruPkgNames := g.extractPackageNames(guruPkgs)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d GURU packages...", len(guruPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing GURU packages: %s", strings.Join(guruPkgNames, ", ")),
}
if err := g.installGURUPackages(ctx, guruPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install GURU packages: %w", err)
}
}
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := g.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (g *GentooDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]PackageMapping, []PackageMapping, []string) {
systemPkgs := []PackageMapping{}
guruPkgs := []PackageMapping{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := g.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
g.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo)
case RepoTypeGURU:
guruPkgs = append(guruPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, guruPkgs, manualPkgs
}
func (g *GentooDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (g *GentooDistribution) installPortagePackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
packageNames := g.extractPackageNames(packages)
g.log(fmt.Sprintf("Installing Portage packages: %s", strings.Join(packageNames, ", ")))
for _, pkg := range packages {
if pkg.AcceptKeywords != "" {
if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil {
return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err)
}
}
if pkg.UseFlags != "" {
if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil {
return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err)
}
}
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("%s || exit $?", strings.Join(args, " ")))
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
}
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
packageUseDir := "/etc/portage/package.use"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", packageUseDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
return fmt.Errorf("failed to create package.use directory: %w", err)
}
useFlagLine := fmt.Sprintf("%s %s", packageName, useFlags)
checkExistingCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, packageUseDir))
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
return fmt.Errorf("failed to remove old USE flags: %w", err)
}
}
appendCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
output, err := appendCmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("append output: %s", string(output)))
return fmt.Errorf("failed to write USE flags to package.use: %w", err)
}
g.log(fmt.Sprintf("Set USE flags for %s: %s", packageName, useFlags))
return nil
}
func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
Step: "Enabling GURU repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo eselect repository enable guru",
LogOutput: "Enabling GURU repository with eselect",
}
// Enable GURU repository
enableCmd := ExecSudoCommand(ctx, sudoPassword,
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
output, err := enableCmd.CombinedOutput()
g.log(fmt.Sprintf("eselect repository enable guru output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
LogOutput: "GURU repository enabled",
}
if err != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.55,
LogOutput: fmt.Sprintf("ERROR enabling GURU: %v", err),
Error: err,
}
return fmt.Errorf("failed to enable GURU repository: %w\nOutput: %s", err, string(output))
}
// Sync GURU repository
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
Step: "Syncing GURU repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo emaint sync --repo guru",
LogOutput: "Syncing GURU repository",
}
syncCmd := ExecSudoCommand(ctx, sudoPassword,
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
syncOutput, syncErr := syncCmd.CombinedOutput()
g.log(fmt.Sprintf("emaint sync --repo guru output:\n%s", string(syncOutput)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
LogOutput: "GURU repository synced",
}
if syncErr != nil {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.57,
LogOutput: fmt.Sprintf("ERROR syncing GURU: %v", syncErr),
Error: syncErr,
}
return fmt.Errorf("failed to sync GURU repository: %w\nOutput: %s", syncErr, string(syncOutput))
}
return nil
}
func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packageName, keywords, sudoPassword string) error {
checkCmd := exec.CommandContext(ctx, "portageq", "match", "/", packageName)
if output, err := checkCmd.CombinedOutput(); err == nil && len(output) > 0 {
g.log(fmt.Sprintf("Package %s is already available (may already be unmasked)", packageName))
return nil
}
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
if output, err := mkdirCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
return fmt.Errorf("failed to create package.accept_keywords directory: %w", err)
}
keywordLine := fmt.Sprintf("%s %s", packageName, keywords)
checkExistingCmd := exec.CommandContext(ctx, "bash", "-c",
fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, acceptKeywordsDir))
if checkExistingCmd.Run() == nil {
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
if output, err := replaceCmd.CombinedOutput(); err != nil {
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
return fmt.Errorf("failed to remove old accept keywords: %w", err)
}
}
appendCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
output, err := appendCmd.CombinedOutput()
if err != nil {
g.log(fmt.Sprintf("append output: %s", string(output)))
return fmt.Errorf("failed to write accept keywords: %w", err)
}
g.log(fmt.Sprintf("Set accept keywords for %s: %s", packageName, keywords))
return nil
}
func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
packageNames := g.extractPackageNames(packages)
g.log(fmt.Sprintf("Installing GURU packages: %s", strings.Join(packageNames, ", ")))
for _, pkg := range packages {
if pkg.AcceptKeywords != "" {
if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil {
return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err)
}
}
if pkg.UseFlags != "" {
if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil {
return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err)
}
}
}
guruPackages := make([]string, len(packageNames))
for i, pkg := range packageNames {
guruPackages[i] = pkg + "::guru"
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing GURU packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("%s || exit $?", strings.Join(args, " ")))
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
}

View File

@@ -0,0 +1,156 @@
package distros
import (
"context"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
// DistroFamily represents a family of related distributions
type DistroFamily string
const (
FamilyArch DistroFamily = "arch"
FamilyFedora DistroFamily = "fedora"
FamilySUSE DistroFamily = "suse"
FamilyUbuntu DistroFamily = "ubuntu"
FamilyDebian DistroFamily = "debian"
FamilyNix DistroFamily = "nix"
FamilyGentoo DistroFamily = "gentoo"
)
// PackageManagerType defines the package manager a distro uses
type PackageManagerType string
const (
PackageManagerPacman PackageManagerType = "pacman"
PackageManagerDNF PackageManagerType = "dnf"
PackageManagerAPT PackageManagerType = "apt"
PackageManagerZypper PackageManagerType = "zypper"
PackageManagerNix PackageManagerType = "nix"
PackageManagerPortage PackageManagerType = "portage"
)
// RepositoryType defines the type of repository for a package
type RepositoryType string
const (
RepoTypeSystem RepositoryType = "system" // Standard system repo (pacman, dnf, apt)
RepoTypeAUR RepositoryType = "aur" // Arch User Repository
RepoTypeCOPR RepositoryType = "copr" // Fedora COPR
RepoTypePPA RepositoryType = "ppa" // Ubuntu PPA
RepoTypeFlake RepositoryType = "flake" // Nix flake
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
RepoTypeManual RepositoryType = "manual" // Manual build from source
)
// InstallPhase represents the current phase of installation
type InstallPhase int
const (
PhasePrerequisites InstallPhase = iota
PhaseAURHelper
PhaseSystemPackages
PhaseAURPackages
PhaseCursorTheme
PhaseConfiguration
PhaseComplete
)
// InstallProgressMsg represents progress during package installation
type InstallProgressMsg struct {
Phase InstallPhase
Progress float64
Step string
IsComplete bool
NeedsSudo bool
CommandInfo string
LogOutput string
Error error
}
// PackageMapping defines how to install a package on a specific distro
type PackageMapping struct {
Name string // Package name to install
Repository RepositoryType // Repository type
RepoURL string // Repository URL if needed (e.g., COPR repo, PPA)
BuildFunc string // Name of manual build function if RepoTypeManual
UseFlags string // USE flags for Gentoo packages
AcceptKeywords string // Accept keywords for Gentoo packages (e.g., "~amd64")
}
// Distribution defines a Linux distribution with all its specific configurations
type Distribution interface {
// Metadata
GetID() string
GetColorHex() string
GetFamily() DistroFamily
GetPackageManager() PackageManagerType
// Dependency Detection
DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error)
DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error)
// Package Installation
InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error
// Package Mapping
GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping
// Prerequisites
InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error
}
// DistroConfig holds configuration for a distribution
type DistroConfig struct {
ID string
ColorHex string
Family DistroFamily
Constructor func(config DistroConfig, logChan chan<- string) Distribution
}
// Registry holds all supported distributions
var Registry = make(map[string]DistroConfig)
// Register adds a distribution to the registry
func Register(id, colorHex string, family DistroFamily, constructor func(config DistroConfig, logChan chan<- string) Distribution) {
Registry[id] = DistroConfig{
ID: id,
ColorHex: colorHex,
Family: family,
Constructor: constructor,
}
}
// GetSupportedDistros returns a list of all supported distribution IDs
func GetSupportedDistros() []string {
ids := make([]string, 0, len(Registry))
for id := range Registry {
ids = append(ids, id)
}
return ids
}
// IsDistroSupported checks if a distribution ID is supported
func IsDistroSupported(id string) bool {
_, exists := Registry[id]
return exists
}
// NewDistribution creates a distribution instance by ID
func NewDistribution(id string, logChan chan<- string) (Distribution, error) {
config, exists := Registry[id]
if !exists {
return nil, &UnsupportedDistributionError{ID: id}
}
return config.Constructor(config, logChan), nil
}
// UnsupportedDistributionError is returned when a distribution is not supported
type UnsupportedDistributionError struct {
ID string
}
func (e *UnsupportedDistributionError) Error() string {
return "unsupported distribution: " + e.ID
}

View File

@@ -0,0 +1,802 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ManualPackageInstaller provides methods for installing packages from source
type ManualPackageInstaller struct {
*BaseDistribution
}
// parseLatestTagFromGitOutput parses git ls-remote output and returns the latest tag
func (m *ManualPackageInstaller) parseLatestTagFromGitOutput(output string) string {
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "refs/tags/") && !strings.Contains(line, "^{}") {
parts := strings.Split(line, "refs/tags/")
if len(parts) > 1 {
latestTag := strings.TrimSpace(parts[1])
return latestTag
}
}
}
return ""
}
// getLatestQuickshellTag fetches the latest tag from the quickshell repository
func (m *ManualPackageInstaller) getLatestQuickshellTag(ctx context.Context) string {
tagCmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", "--sort=-v:refname",
"https://github.com/quickshell-mirror/quickshell.git")
tagOutput, err := tagCmd.Output()
if err != nil {
m.log(fmt.Sprintf("Warning: failed to fetch quickshell tags: %v", err))
return ""
}
return m.parseLatestTagFromGitOutput(string(tagOutput))
}
// InstallManualPackages handles packages that need manual building
func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
m.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
switch pkg {
case "dms (DankMaterialShell)", "dms":
if err := m.installDankMaterialShell(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install DankMaterialShell: %w", err)
}
case "dgop":
if err := m.installDgop(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install dgop: %w", err)
}
case "grimblast":
if err := m.installGrimblast(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install grimblast: %w", err)
}
case "niri":
if err := m.installNiri(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install niri: %w", err)
}
case "quickshell":
if err := m.installQuickshell(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
case "hyprland":
if err := m.installHyprland(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install hyprland: %w", err)
}
case "hyprpicker":
if err := m.installHyprpicker(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install hyprpicker: %w", err)
}
case "ghostty":
if err := m.installGhostty(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
case "matugen":
if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install matugen: %w", err)
}
case "cliphist":
if err := m.installCliphist(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install cliphist: %w", err)
}
case "xwayland-satellite":
if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
}
default:
m.log(fmt.Sprintf("Warning: No manual build method for %s", pkg))
}
}
return nil
}
func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing dgop from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "dgop-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning dgop repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/dgop.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/AvengeMedia/dgop.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
m.logError("failed to clone dgop repository", err)
return fmt.Errorf("failed to clone dgop repository: %w", err)
}
buildCmd := exec.CommandContext(ctx, "make")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.7, "Building dgop..."); err != nil {
return fmt.Errorf("failed to build dgop: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing dgop...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err)
return fmt.Errorf("failed to install dgop: %w", err)
}
m.log("dgop installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installGrimblast(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing grimblast script for Hyprland...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Downloading grimblast script...",
IsComplete: false,
CommandInfo: "curl grimblast script",
}
grimblastURL := "https://raw.githubusercontent.com/hyprwm/contrib/refs/heads/main/grimblast/grimblast"
tmpPath := filepath.Join(os.TempDir(), "grimblast")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", "-o", tmpPath, grimblastURL)
if err := downloadCmd.Run(); err != nil {
m.logError("failed to download grimblast", err)
return fmt.Errorf("failed to download grimblast: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.5,
Step: "Making grimblast executable...",
IsComplete: false,
CommandInfo: "chmod +x grimblast",
}
chmodCmd := exec.CommandContext(ctx, "chmod", "+x", tmpPath)
if err := chmodCmd.Run(); err != nil {
m.logError("failed to make grimblast executable", err)
return fmt.Errorf("failed to make grimblast executable: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing grimblast to /usr/local/bin...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp grimblast /usr/local/bin/",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s /usr/local/bin/grimblast", tmpPath))
if err := installCmd.Run(); err != nil {
m.logError("failed to install grimblast", err)
return fmt.Errorf("failed to install grimblast: %w", err)
}
os.Remove(tmpPath)
m.log("grimblast installed successfully to /usr/local/bin")
return nil
}
func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing niri from source...")
homeDir, _ := os.UserHomeDir()
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build")
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp")
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() {
os.RemoveAll(buildDir)
os.RemoveAll(tmpDir)
}()
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Cloning niri repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/YaLTeR/niri.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/YaLTeR/niri.git", buildDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone niri: %w", err)
}
checkoutCmd := exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "v25.08")
if err := checkoutCmd.Run(); err != nil {
m.log(fmt.Sprintf("Warning: failed to checkout v25.08, using main: %v", err))
}
if !m.commandExists("cargo-deb") {
cargoDebInstallCmd := exec.CommandContext(ctx, "cargo", "install", "cargo-deb")
cargoDebInstallCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir)
if err := m.runWithProgressStep(cargoDebInstallCmd, progressChan, PhaseSystemPackages, 0.3, 0.35, "Installing cargo-deb..."); err != nil {
return fmt.Errorf("failed to install cargo-deb: %w", err)
}
}
buildDebCmd := exec.CommandContext(ctx, "cargo", "deb")
buildDebCmd.Dir = buildDir
buildDebCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir)
if err := m.runWithProgressStep(buildDebCmd, progressChan, PhaseSystemPackages, 0.35, 0.95, "Building niri deb package..."); err != nil {
return fmt.Errorf("failed to build niri deb: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.95,
Step: "Installing niri deb package...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "dpkg -i niri.deb",
}
installDebCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
output, err := installDebCmd.CombinedOutput()
if err != nil {
m.log(fmt.Sprintf("dpkg install failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to install niri deb package: %w\nOutput:\n%s", err, string(output))
}
m.log(fmt.Sprintf("dpkg install successful. Output:\n%s", string(output)))
m.log("niri installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing quickshell from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning quickshell repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git",
}
var cloneCmd *exec.Cmd
if forceQuickshellGit {
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
// Get latest tag from repository
latestTag := m.getLatestQuickshellTag(ctx)
if latestTag != "" {
m.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag))
cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
m.log("Warning: failed to fetch latest tag, using default branch")
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
}
}
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone quickshell: %w", err)
}
buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.3,
Step: "Configuring quickshell build...",
IsComplete: false,
CommandInfo: "cmake -B build -S . -G Ninja",
}
configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build",
"-DCMAKE_BUILD_TYPE=RelWithDebInfo",
"-DCRASH_REPORTER=off",
"-DCMAKE_CXX_STANDARD=20")
configureCmd.Dir = tmpDir
configureCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
output, err := configureCmd.CombinedOutput()
if err != nil {
m.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output))
}
m.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building quickshell (this may take a while)...",
IsComplete: false,
CommandInfo: "cmake --build build",
}
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil {
return fmt.Errorf("failed to build quickshell: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && cmake --install build", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
m.log("quickshell installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing Hyprland from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "hyprland-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning Hyprland repository...",
IsComplete: false,
CommandInfo: "git clone --recursive https://github.com/hyprwm/Hyprland.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "--recursive", "https://github.com/hyprwm/Hyprland.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone Hyprland: %w", err)
}
checkoutCmd := exec.CommandContext(ctx, "git", "-C", tmpDir, "checkout", "v0.50.1")
if err := checkoutCmd.Run(); err != nil {
m.log(fmt.Sprintf("Warning: failed to checkout v0.50.1, using main: %v", err))
}
buildCmd := exec.CommandContext(ctx, "make", "all")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.2, 0.8, "Building Hyprland..."); err != nil {
return fmt.Errorf("failed to build Hyprland: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing Hyprland...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && make install", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Hyprland: %w", err)
}
m.log("Hyprland installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installHyprpicker(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing hyprpicker from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "hyprpicker-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Cloning hyprpicker repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/hyprwm/hyprpicker.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/hyprwm/hyprpicker.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone hyprpicker: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building hyprpicker...",
IsComplete: false,
CommandInfo: "make all",
}
buildCmd := exec.CommandContext(ctx, "make", "all")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("failed to build hyprpicker: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing hyprpicker...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && make install", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install hyprpicker: %w", err)
}
m.log("hyprpicker installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing Ghostty from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "ghostty-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning Ghostty repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/ghostty-org/ghostty.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/ghostty-org/ghostty.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone Ghostty: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.2,
Step: "Building Ghostty (this may take a while)...",
IsComplete: false,
CommandInfo: "zig build -Doptimize=ReleaseFast",
}
buildCmd := exec.CommandContext(ctx, "zig", "build", "-Doptimize=ReleaseFast")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("failed to build Ghostty: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing Ghostty...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
m.log("Ghostty installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing matugen from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing matugen via cargo...",
IsComplete: false,
CommandInfo: "cargo install matugen",
}
installCmd := exec.CommandContext(ctx, "cargo", "install", "matugen")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building matugen..."); err != nil {
return fmt.Errorf("failed to install matugen: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, ".cargo", "bin", "matugen")
targetPath := "/usr/local/bin/matugen"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing matugen binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make matugen executable: %w", err)
}
m.log("matugen installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing DankMaterialShell (DMS)...")
// Always install/update the DMS binary
if err := m.installDMSBinary(ctx, sudoPassword, progressChan); err != nil {
m.logError("Failed to install DMS binary", err)
}
// Handle DMS config - clone if missing, pull if exists
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
// Config doesn't exist, clone it
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.90,
Step: "Cloning DankMaterialShell config...",
IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/DankMaterialShell.git ~/.config/quickshell/dms",
}
configDir := filepath.Dir(dmsPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create quickshell config directory: %w", err)
}
cloneCmd := exec.CommandContext(ctx, "git", "clone",
"https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone DankMaterialShell: %w", err)
}
if !forceDMSGit {
fetchCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "fetch", "--tags")
if err := fetchCmd.Run(); err == nil {
tagCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "describe", "--tags", "--abbrev=0", "origin/master")
if tagOutput, err := tagCmd.Output(); err == nil {
latestTag := strings.TrimSpace(string(tagOutput))
checkoutCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "checkout", latestTag)
if err := checkoutCmd.Run(); err == nil {
m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag))
}
}
}
}
m.log("DankMaterialShell config cloned successfully")
} else {
// Config exists, update it
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.90,
Step: "Updating DankMaterialShell config...",
IsComplete: false,
CommandInfo: "git pull in ~/.config/quickshell/dms",
}
pullCmd := exec.CommandContext(ctx, "git", "pull")
pullCmd.Dir = dmsPath
if err := pullCmd.Run(); err != nil {
m.logError("Failed to update DankMaterialShell config", err)
} else {
m.log("DankMaterialShell config updated successfully")
}
}
return nil
}
func (m *ManualPackageInstaller) installCliphist(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing cliphist from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing cliphist via go install...",
IsComplete: false,
CommandInfo: "go install go.senan.xyz/cliphist@latest",
}
installCmd := exec.CommandContext(ctx, "go", "install", "go.senan.xyz/cliphist@latest")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building cliphist..."); err != nil {
return fmt.Errorf("failed to install cliphist: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, "go", "bin", "cliphist")
targetPath := "/usr/local/bin/cliphist"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing cliphist binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy cliphist to /usr/local/bin: %w", err)
}
// Make it executable
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make cliphist executable: %w", err)
}
m.log("cliphist installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing xwayland-satellite from source...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Installing xwayland-satellite via cargo...",
IsComplete: false,
CommandInfo: "cargo install --git https://github.com/Supreeeme/xwayland-satellite --tag v0.7",
}
installCmd := exec.CommandContext(ctx, "cargo", "install", "--git", "https://github.com/Supreeeme/xwayland-satellite", "--tag", "v0.7")
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building xwayland-satellite..."); err != nil {
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
}
homeDir := os.Getenv("HOME")
sourcePath := filepath.Join(homeDir, ".cargo", "bin", "xwayland-satellite")
targetPath := "/usr/local/bin/xwayland-satellite"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing xwayland-satellite binary to system...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
}
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := copyCmd.Run(); err != nil {
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
}
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
if err := chmodCmd.Run(); err != nil {
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
}
m.log("xwayland-satellite installed successfully from source")
return nil
}

View File

@@ -0,0 +1,122 @@
package distros
import (
"testing"
)
func TestManualPackageInstaller_parseLatestTagFromGitOutput(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "normal tag output",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0
703a3789083d2f990c4e99cd25c97c2a4cccbd81 refs/tags/v0.1.0`,
expected: "v0.2.1",
},
{
name: "annotated tags with ^{}",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.2.1^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.2.1",
},
{
name: "mixed tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0
b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.3.0^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0
c1c150fab00a93ea983aaca5df55304bc837f51d refs/tags/beta-1`,
expected: "v0.3.0",
},
{
name: "empty output",
input: "",
expected: "",
},
{
name: "no tags",
input: "some other output\nwithout tags",
expected: "",
},
{
name: "only annotated tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1^{}
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0^{}`,
expected: "",
},
{
name: "single tag",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.0.0`,
expected: "v1.0.0",
},
{
name: "tag with extra whitespace",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.2.1",
},
{
name: "beta and rc tags",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0-beta.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`,
expected: "v0.3.0-beta.1",
},
{
name: "tags without v prefix",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/0.2.1
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/0.2.0`,
expected: "0.2.1",
},
{
name: "multiple lines with spaces",
input: `
a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.2.3
a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v1.2.2
`,
expected: "v1.2.3",
},
{
name: "tag at end of line",
input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1`,
expected: "v0.2.1",
},
}
logChan := make(chan string, 100)
defer close(logChan)
base := NewBaseDistribution(logChan)
installer := &ManualPackageInstaller{BaseDistribution: base}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := installer.parseLatestTagFromGitOutput(tt.input)
if result != tt.expected {
t.Errorf("parseLatestTagFromGitOutput() = %q, expected %q", result, tt.expected)
}
})
}
}
func TestManualPackageInstaller_parseLatestTagFromGitOutput_EmptyInstaller(t *testing.T) {
// Test that parsing works even with a minimal installer setup
logChan := make(chan string, 10)
defer close(logChan)
base := NewBaseDistribution(logChan)
installer := &ManualPackageInstaller{BaseDistribution: base}
input := `abc123 refs/tags/v1.0.0
def456 refs/tags/v0.9.0`
result := installer.parseLatestTagFromGitOutput(input)
if result != "v1.0.0" {
t.Errorf("Expected v1.0.0, got %s", result)
}
}

View File

@@ -0,0 +1,461 @@
package distros
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
func init() {
Register("nixos", "#7EBAE4", FamilyNix, func(config DistroConfig, logChan chan<- string) Distribution {
return NewNixOSDistribution(config, logChan)
})
}
type NixOSDistribution struct {
*BaseDistribution
config DistroConfig
}
func NewNixOSDistribution(config DistroConfig, logChan chan<- string) *NixOSDistribution {
base := NewBaseDistribution(logChan)
return &NixOSDistribution{
BaseDistribution: base,
config: config,
}
}
func (n *NixOSDistribution) GetID() string {
return n.config.ID
}
func (n *NixOSDistribution) GetColorHex() string {
return n.config.ColorHex
}
func (n *NixOSDistribution) GetFamily() DistroFamily {
return n.config.Family
}
func (n *NixOSDistribution) GetPackageManager() PackageManagerType {
return PackageManagerNix
}
func (n *NixOSDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return n.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (n *NixOSDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, n.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, n.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, n.detectGit())
dependencies = append(dependencies, n.detectWindowManager(wm))
dependencies = append(dependencies, n.detectQuickshell())
dependencies = append(dependencies, n.detectXDGPortal())
dependencies = append(dependencies, n.detectPolkitAgent())
dependencies = append(dependencies, n.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, n.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, n.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, n.detectMatugen())
dependencies = append(dependencies, n.detectDgop())
dependencies = append(dependencies, n.detectHyprpicker())
dependencies = append(dependencies, n.detectClipboardTools()...)
return dependencies, nil
}
func (n *NixOSDistribution) detectDMS() deps.Dependency {
status := deps.StatusMissing
// For NixOS, check if quickshell can find the dms config
cmd := exec.Command("qs", "-c", "dms", "--list")
if err := cmd.Run(); err == nil {
status = deps.StatusInstalled
} else if n.packageInstalled("DankMaterialShell") {
// Fallback: check if flake is in profile
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Description: "Desktop Management System configuration (installed as flake)",
Required: true,
}
}
func (n *NixOSDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (n *NixOSDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency {
switch wm {
case deps.WindowManagerHyprland:
status := deps.StatusMissing
description := "Dynamic tiling Wayland compositor"
if n.commandExists("hyprland") || n.commandExists("Hyprland") {
status = deps.StatusInstalled
} else {
description = "Install system-wide: programs.hyprland.enable = true; in configuration.nix"
}
return deps.Dependency{
Name: "hyprland",
Status: status,
Description: description,
Required: true,
}
case deps.WindowManagerNiri:
status := deps.StatusMissing
description := "Scrollable-tiling Wayland compositor"
if n.commandExists("niri") {
status = deps.StatusInstalled
} else {
description = "Install system-wide: environment.systemPackages = [ pkgs.niri ]; in configuration.nix"
}
return deps.Dependency{
Name: "niri",
Status: status,
Description: description,
Required: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
Status: deps.StatusMissing,
Description: "Unknown window manager",
Required: true,
}
}
}
func (n *NixOSDistribution) detectHyprlandTools() []deps.Dependency {
var dependencies []deps.Dependency
tools := []struct {
name string
description string
}{
{"grim", "Screenshot utility for Wayland"},
{"slurp", "Region selection utility for Wayland"},
{"hyprctl", "Hyprland control utility (comes with system Hyprland)"},
{"hyprpicker", "Color picker for Hyprland"},
{"grimblast", "Screenshot script for Hyprland"},
{"jq", "JSON processor"},
}
for _, tool := range tools {
status := deps.StatusMissing
// Special handling for hyprctl - it comes with system hyprland
if tool.name == "hyprctl" {
if n.commandExists("hyprctl") {
status = deps.StatusInstalled
}
} else {
if n.commandExists(tool.name) {
status = deps.StatusInstalled
}
}
dependencies = append(dependencies, deps.Dependency{
Name: tool.name,
Status: status,
Description: tool.description,
Required: true,
})
}
return dependencies
}
func (n *NixOSDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if n.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (n *NixOSDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (n *NixOSDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if n.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (n *NixOSDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("nix", "profile", "list")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), pkg)
}
func (n *NixOSDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "nixpkgs#git", Repository: RepoTypeSystem},
"quickshell": {Name: "github:quickshell-mirror/quickshell", Repository: RepoTypeFlake},
"matugen": {Name: "github:InioX/matugen", Repository: RepoTypeFlake},
"dgop": {Name: "github:AvengeMedia/dgop", Repository: RepoTypeFlake},
"dms (DankMaterialShell)": {Name: "github:AvengeMedia/DankMaterialShell", Repository: RepoTypeFlake},
"ghostty": {Name: "nixpkgs#ghostty", Repository: RepoTypeSystem},
"alacritty": {Name: "nixpkgs#alacritty", Repository: RepoTypeSystem},
"cliphist": {Name: "nixpkgs#cliphist", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "nixpkgs#wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "nixpkgs#xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "nixpkgs#mate.mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "nixpkgs#accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "nixpkgs#hyprpicker", Repository: RepoTypeSystem},
}
// Note: Window managers (hyprland/niri) should be installed system-wide on NixOS
// We only install the tools here
switch wm {
case deps.WindowManagerHyprland:
// Skip hyprland itself - should be installed system-wide
packages["grim"] = PackageMapping{Name: "nixpkgs#grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "nixpkgs#slurp", Repository: RepoTypeSystem}
packages["grimblast"] = PackageMapping{Name: "github:hyprwm/contrib#grimblast", Repository: RepoTypeFlake}
packages["jq"] = PackageMapping{Name: "nixpkgs#jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
// Skip niri itself - should be installed system-wide
packages["xwayland-satellite"] = PackageMapping{Name: "nixpkgs#xwayland-satellite", Repository: RepoTypeFlake}
}
return packages
}
func (n *NixOSDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "NixOS prerequisites ready",
IsComplete: false,
LogOutput: "NixOS package manager is ready to use",
}
return nil
}
func (n *NixOSDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := n.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
nixpkgsPkgs, flakePkgs := n.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 2: Nixpkgs Packages
if len(nixpkgsPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d packages from nixpkgs...", len(nixpkgsPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing nixpkgs packages: %s", strings.Join(nixpkgsPkgs, ", ")),
}
if err := n.installNixpkgsPackages(ctx, nixpkgsPkgs, progressChan); err != nil {
return fmt.Errorf("failed to install nixpkgs packages: %w", err)
}
}
// Phase 3: Flake Packages
if len(flakePkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.65,
Step: fmt.Sprintf("Installing %d packages from flakes...", len(flakePkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing flake packages: %s", strings.Join(flakePkgs, ", ")),
}
if err := n.installFlakePackages(ctx, flakePkgs, progressChan); err != nil {
return fmt.Errorf("failed to install flake packages: %w", err)
}
}
// Phase 4: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
if err := n.postInstallConfig(progressChan); err != nil {
return fmt.Errorf("failed to configure system: %w", err)
}
// Phase 5: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (n *NixOSDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string) {
nixpkgsPkgs := []string{}
flakePkgs := []string{}
packageMap := n.GetPackageMapping(wm)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
n.log(fmt.Sprintf("Warning: No package mapping found for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
nixpkgsPkgs = append(nixpkgsPkgs, pkgInfo.Name)
case RepoTypeFlake:
flakePkgs = append(flakePkgs, pkgInfo.Name)
}
}
return nixpkgsPkgs, flakePkgs
}
func (n *NixOSDistribution) installNixpkgsPackages(ctx context.Context, packages []string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
n.log(fmt.Sprintf("Installing nixpkgs packages: %s", strings.Join(packages, ", ")))
args := []string{"profile", "install"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing nixpkgs packages...",
IsComplete: false,
CommandInfo: fmt.Sprintf("nix %s", strings.Join(args, " ")),
}
cmd := exec.CommandContext(ctx, "nix", args...)
return n.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (n *NixOSDistribution) installFlakePackages(ctx context.Context, packages []string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
n.log(fmt.Sprintf("Installing flake packages: %s", strings.Join(packages, ", ")))
baseProgress := 0.65
progressStep := 0.20 / float64(len(packages))
for i, pkg := range packages {
currentProgress := baseProgress + (float64(i) * progressStep)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: currentProgress,
Step: fmt.Sprintf("Installing flake package %s (%d/%d)...", pkg, i+1, len(packages)),
IsComplete: false,
CommandInfo: fmt.Sprintf("nix profile install %s", pkg),
}
cmd := exec.CommandContext(ctx, "nix", "profile", "install", pkg)
if err := n.runWithProgress(cmd, progressChan, PhaseAURPackages, currentProgress, currentProgress+progressStep); err != nil {
return fmt.Errorf("failed to install flake package %s: %w", pkg, err)
}
}
return nil
}
func (n *NixOSDistribution) postInstallConfig(progressChan chan<- InstallProgressMsg) error {
// For NixOS, DMS is installed as a flake package, so we skip both the binary installation and git clone
// The flake installation handles both the binary and config files correctly
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.95,
Step: "NixOS configuration complete",
IsComplete: false,
LogOutput: "DMS installed via flake - binary and config handled by Nix",
}
return nil
}

View File

@@ -0,0 +1,608 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
func init() {
Register("opensuse-tumbleweed", "#73BA25", FamilySUSE, func(config DistroConfig, logChan chan<- string) Distribution {
return NewOpenSUSEDistribution(config, logChan)
})
}
type OpenSUSEDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewOpenSUSEDistribution(config DistroConfig, logChan chan<- string) *OpenSUSEDistribution {
base := NewBaseDistribution(logChan)
return &OpenSUSEDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (o *OpenSUSEDistribution) GetID() string {
return o.config.ID
}
func (o *OpenSUSEDistribution) GetColorHex() string {
return o.config.ColorHex
}
func (o *OpenSUSEDistribution) GetFamily() DistroFamily {
return o.config.Family
}
func (o *OpenSUSEDistribution) GetPackageManager() PackageManagerType {
return PackageManagerZypper
}
func (o *OpenSUSEDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return o.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, o.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, o.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, o.detectGit())
dependencies = append(dependencies, o.detectWindowManager(wm))
dependencies = append(dependencies, o.detectQuickshell())
dependencies = append(dependencies, o.detectXDGPortal())
dependencies = append(dependencies, o.detectPolkitAgent())
dependencies = append(dependencies, o.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, o.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, o.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDgop())
dependencies = append(dependencies, o.detectHyprpicker())
dependencies = append(dependencies, o.detectClipboardTools()...)
return dependencies, nil
}
func (o *OpenSUSEDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (o *OpenSUSEDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("rpm", "-q", pkg)
err := cmd.Run()
return err == nil
}
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return o.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
// Standard zypper packages
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypeSystem},
// Manual builds
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem}
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (o *OpenSUSEDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if o.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (o *OpenSUSEDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if o.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (o *OpenSUSEDistribution) getPrerequisites() []string {
return []string{
"make",
"unzip",
"gcc",
"gcc-c++",
"cmake",
"ninja",
"pkgconf-pkg-config",
"git",
"qt6-base-devel",
"qt6-declarative-devel",
"qt6-declarative-private-devel",
"qt6-shadertools",
"qt6-shadertools-devel",
"qt6-wayland-devel",
"qt6-waylandclient-private-devel",
"spirv-tools-devel",
"cli11-devel",
"wayland-protocols-devel",
"libgbm-devel",
"libdrm-devel",
"pipewire-devel",
"jemalloc-devel",
"wayland-utils",
"Mesa-libGLESv3-devel",
"pam-devel",
"glib2-devel",
"polkit-devel",
}
}
func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
prerequisites := o.getPrerequisites()
var missingPkgs []string
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking prerequisites...",
IsComplete: false,
LogOutput: "Checking prerequisite packages",
}
for _, pkg := range prerequisites {
checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg)
if err := checkCmd.Run(); err != nil {
missingPkgs = append(missingPkgs, pkg)
}
}
_, err := exec.LookPath("go")
if err != nil {
o.log("go not found in PATH, will install go")
missingPkgs = append(missingPkgs, "go")
} else {
o.log("go already available in PATH")
}
if len(missingPkgs) == 0 {
o.log("All prerequisites already installed")
return nil
}
o.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo zypper install -y %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"zypper", "install", "-y"}
args = append(args, missingPkgs...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
o.logError("failed to install prerequisites", err)
o.log(fmt.Sprintf("Prerequisites command output: %s", string(output)))
return fmt.Errorf("failed to install prerequisites: %w", err)
}
o.log(fmt.Sprintf("Prerequisites install output: %s", string(output)))
return nil
}
func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := o.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, manualPkgs := o.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 2: System Packages (Zypper)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install zypper packages: %w", err)
}
}
// Phase 3: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := o.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 4: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 5: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (o *OpenSUSEDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string) {
systemPkgs := []string{}
manualPkgs := []string{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := o.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
o.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, manualPkgs
}
func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing zypper packages: %s", strings.Join(packages, ", ")))
args := []string{"zypper", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
// installQuickshell overrides the base implementation to set openSUSE-specific CFLAGS
func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
o.log("Installing quickshell from source (with openSUSE-specific build flags)...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning quickshell repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git",
}
var cloneCmd *exec.Cmd
if forceQuickshellGit {
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
// Get latest tag from repository
latestTag := o.getLatestQuickshellTag(ctx)
if latestTag != "" {
o.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag))
cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
} else {
o.log("Warning: failed to fetch latest tag, using default branch")
cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir)
}
}
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone quickshell: %w", err)
}
buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.3,
Step: "Configuring quickshell build (with openSUSE flags)...",
IsComplete: false,
CommandInfo: "cmake -B build -S . -G Ninja",
}
// Get optflags from rpm
optflagsCmd := exec.CommandContext(ctx, "rpm", "--eval", "%{optflags}")
optflagsOutput, err := optflagsCmd.Output()
optflags := strings.TrimSpace(string(optflagsOutput))
if err != nil || optflags == "" {
o.log("Warning: Could not get optflags from rpm, using default -O2 -g")
optflags = "-O2 -g"
}
// Set openSUSE-specific CFLAGS
customCFLAGS := fmt.Sprintf("%s -I/usr/include/wayland", optflags)
configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build",
"-DCMAKE_BUILD_TYPE=RelWithDebInfo",
"-DCRASH_REPORTER=off",
"-DCMAKE_CXX_STANDARD=20")
configureCmd.Dir = tmpDir
configureCmd.Env = append(os.Environ(),
"TMPDIR="+cacheDir,
"CFLAGS="+customCFLAGS,
"CXXFLAGS="+customCFLAGS)
o.log(fmt.Sprintf("Using CFLAGS: %s", customCFLAGS))
output, err := configureCmd.CombinedOutput()
if err != nil {
o.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output)))
return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output))
}
o.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output)))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.4,
Step: "Building quickshell (this may take a while)...",
IsComplete: false,
CommandInfo: "cmake --build build",
}
buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(),
"TMPDIR="+cacheDir,
"CFLAGS="+customCFLAGS,
"CXXFLAGS="+customCFLAGS)
if err := o.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil {
return fmt.Errorf("failed to build quickshell: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.8,
Step: "Installing quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo cmake --install build",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("cd %s && cmake --install build", tmpDir))
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
o.log("quickshell installed successfully from source")
return nil
}
func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if o.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo zypper install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup")
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := o.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
if !o.commandExists("cargo") {
o.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
// InstallManualPackages overrides the base implementation to use openSUSE-specific builds
func (o *OpenSUSEDistribution) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
o.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
// Install Rust if needed for matugen
for _, pkg := range packages {
if pkg == "matugen" {
if err := o.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
break
}
}
for _, pkg := range packages {
if pkg == "quickshell" {
if err := o.installQuickshell(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install quickshell: %w", err)
}
} else {
// Use the base ManualPackageInstaller for other packages
if err := o.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

View File

@@ -0,0 +1,115 @@
package distros
import (
"bufio"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
)
// DistroInfo contains basic information about a distribution
type DistroInfo struct {
ID string
HexColorCode string
}
// OSInfo contains complete OS information
type OSInfo struct {
Distribution DistroInfo
Version string
VersionID string
PrettyName string
Architecture string
}
// GetOSInfo detects the current OS and returns information about it
func GetOSInfo() (*OSInfo, error) {
if runtime.GOOS != "linux" {
return nil, errdefs.NewCustomError(errdefs.ErrTypeNotLinux, fmt.Sprintf("Only linux is supported, but I found %s", runtime.GOOS))
}
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
}
info := &OSInfo{
Architecture: runtime.GOARCH,
}
file, err := os.Open("/etc/os-release")
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := strings.Trim(parts[1], "\"")
switch key {
case "ID":
config, exists := Registry[value]
if !exists {
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value))
}
info.Distribution = DistroInfo{
ID: value, // Use the actual ID from os-release
HexColorCode: config.ColorHex,
}
case "VERSION_ID", "BUILD_ID":
info.VersionID = value
case "VERSION":
info.Version = value
case "PRETTY_NAME":
info.PrettyName = value
}
}
return info, scanner.Err()
}
// IsUnsupportedDistro checks if a distribution/version combination is supported
func IsUnsupportedDistro(distroID, versionID string) bool {
if !IsDistroSupported(distroID) {
return true
}
if distroID == "ubuntu" {
parts := strings.Split(versionID, ".")
if len(parts) >= 2 {
major, err1 := strconv.Atoi(parts[0])
minor, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil {
return major < 25 || (major == 25 && minor < 4)
}
}
return true
}
if distroID == "debian" {
if versionID == "" {
// debian testing/sid have no version ID
return false
}
versionNum, err := strconv.Atoi(versionID)
if err == nil {
return versionNum < 12
}
return true
}
return false
}

View File

@@ -0,0 +1,755 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
)
func init() {
Register("ubuntu", "#E95420", FamilyUbuntu, func(config DistroConfig, logChan chan<- string) Distribution {
return NewUbuntuDistribution(config, logChan)
})
}
type UbuntuDistribution struct {
*BaseDistribution
*ManualPackageInstaller
config DistroConfig
}
func NewUbuntuDistribution(config DistroConfig, logChan chan<- string) *UbuntuDistribution {
base := NewBaseDistribution(logChan)
return &UbuntuDistribution{
BaseDistribution: base,
ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base},
config: config,
}
}
func (u *UbuntuDistribution) GetID() string {
return u.config.ID
}
func (u *UbuntuDistribution) GetColorHex() string {
return u.config.ColorHex
}
func (u *UbuntuDistribution) GetFamily() DistroFamily {
return u.config.Family
}
func (u *UbuntuDistribution) GetPackageManager() PackageManagerType {
return PackageManagerAPT
}
func (u *UbuntuDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return u.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
// DMS at the top (shell is prominent)
dependencies = append(dependencies, u.detectDMS())
// Terminal with choice support
dependencies = append(dependencies, u.detectSpecificTerminal(terminal))
// Common detections using base methods
dependencies = append(dependencies, u.detectGit())
dependencies = append(dependencies, u.detectWindowManager(wm))
dependencies = append(dependencies, u.detectQuickshell())
dependencies = append(dependencies, u.detectXDGPortal())
dependencies = append(dependencies, u.detectPolkitAgent())
dependencies = append(dependencies, u.detectAccountsService())
// Hyprland-specific tools
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, u.detectHyprlandTools()...)
}
// Niri-specific tools
if wm == deps.WindowManagerNiri {
dependencies = append(dependencies, u.detectXwaylandSatellite())
}
// Base detections (common across distros)
dependencies = append(dependencies, u.detectMatugen())
dependencies = append(dependencies, u.detectDgop())
dependencies = append(dependencies, u.detectHyprpicker())
dependencies = append(dependencies, u.detectClipboardTools()...)
return dependencies, nil
}
func (u *UbuntuDistribution) detectXDGPortal() deps.Dependency {
status := deps.StatusMissing
if u.packageInstalled("xdg-desktop-portal-gtk") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xdg-desktop-portal-gtk",
Status: status,
Description: "Desktop integration portal for GTK",
Required: true,
}
}
func (u *UbuntuDistribution) detectPolkitAgent() deps.Dependency {
status := deps.StatusMissing
if u.packageInstalled("mate-polkit") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "mate-polkit",
Status: status,
Description: "PolicyKit authentication agent",
Required: true,
}
}
func (u *UbuntuDistribution) detectXwaylandSatellite() deps.Dependency {
status := deps.StatusMissing
if u.commandExists("xwayland-satellite") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "xwayland-satellite",
Status: status,
Description: "Xwayland support",
Required: true,
}
}
func (u *UbuntuDistribution) detectAccountsService() deps.Dependency {
status := deps.StatusMissing
if u.packageInstalled("accountsservice") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "accountsservice",
Status: status,
Description: "D-Bus interface for user account query and manipulation",
Required: true,
}
}
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
cmd := exec.Command("dpkg", "-l", pkg)
err := cmd.Run()
return err == nil
}
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
packages := map[string]PackageMapping{
// Standard APT packages
"git": {Name: "git", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"mate-polkit": {Name: "mate-polkit", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"hyprpicker": {Name: "hyprpicker", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"},
// Manual builds (niri and quickshell likely not available in Ubuntu repos or PPAs)
"dms (DankMaterialShell)": {Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"},
"niri": {Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"},
"quickshell": {Name: "quickshell", Repository: RepoTypeManual, BuildFunc: "installQuickshell"},
"ghostty": {Name: "ghostty", Repository: RepoTypeManual, BuildFunc: "installGhostty"},
"matugen": {Name: "matugen", Repository: RepoTypeManual, BuildFunc: "installMatugen"},
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
"cliphist": {Name: "cliphist", Repository: RepoTypeManual, BuildFunc: "installCliphist"},
}
switch wm {
case deps.WindowManagerHyprland:
// Use the cppiber PPA for Hyprland
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
packages["grim"] = PackageMapping{Name: "grim", Repository: RepoTypeSystem}
packages["slurp"] = PackageMapping{Name: "slurp", Repository: RepoTypeSystem}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
packages["grimblast"] = PackageMapping{Name: "grimblast", Repository: RepoTypeManual, BuildFunc: "installGrimblast"}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeManual, BuildFunc: "installNiri"}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeManual, BuildFunc: "installXwaylandSatellite"}
}
return packages
}
func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Updating package lists...",
IsComplete: false,
LogOutput: "Updating APT package lists",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
return fmt.Errorf("failed to update package lists: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.08,
Step: "Installing build-essential...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y build-essential",
LogOutput: "Installing build tools",
}
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
if err := checkCmd.Run(); err != nil {
// Not installed, install it
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
return fmt.Errorf("failed to install build-essential: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.10,
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev")
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.12,
Step: "Prerequisites installation complete",
IsComplete: false,
LogOutput: "Prerequisites successfully installed",
}
return nil
}
func (u *UbuntuDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
// Phase 1: Check Prerequisites
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if err := u.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, ppaPkgs, manualPkgs := u.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
// Phase 2: Enable PPA repositories
if len(ppaPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling PPA repositories...",
IsComplete: false,
LogOutput: "Setting up PPA repositories for additional packages",
}
if err := u.enablePPARepos(ctx, ppaPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable PPA repositories: %w", err)
}
}
// Phase 3: System Packages (APT)
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")),
}
if err := u.installAPTPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install APT packages: %w", err)
}
}
// Phase 4: PPA Packages
ppaPkgNames := u.extractPackageNames(ppaPkgs)
if len(ppaPkgNames) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, // Reusing AUR phase for PPA
Progress: 0.65,
Step: fmt.Sprintf("Installing %d PPA packages...", len(ppaPkgNames)),
IsComplete: false,
LogOutput: fmt.Sprintf("Installing PPA packages: %s", strings.Join(ppaPkgNames, ", ")),
}
if err := u.installPPAPackages(ctx, ppaPkgNames, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install PPA packages: %w", err)
}
}
// Phase 5: Manual Builds
if len(manualPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.80,
Step: "Installing build dependencies...",
IsComplete: false,
LogOutput: "Installing build tools for manual compilation",
}
if err := u.installBuildDependencies(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install build dependencies: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.85,
Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)),
IsComplete: false,
LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")),
}
if err := u.InstallManualPackages(ctx, manualPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install manual packages: %w", err)
}
}
// Phase 6: Configuration
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
// Phase 7: Complete
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (u *UbuntuDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping, []string) {
systemPkgs := []string{}
ppaPkgs := []PackageMapping{}
manualPkgs := []string{}
packageMap := u.GetPackageMapping(wm)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
u.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
case RepoTypePPA:
ppaPkgs = append(ppaPkgs, pkgInfo)
case RepoTypeManual:
manualPkgs = append(manualPkgs, dep.Name)
}
}
return systemPkgs, ppaPkgs, manualPkgs
}
func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
installPPACmd := ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y software-properties-common")
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
return fmt.Errorf("failed to install software-properties-common: %w", err)
}
for _, pkg := range ppaPkgs {
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
u.log(fmt.Sprintf("Enabling PPA repository: %s", pkg.RepoURL))
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.20,
Step: fmt.Sprintf("Enabling PPA repo %s...", pkg.RepoURL),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
}
cmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
return fmt.Errorf("failed to enable PPA repo %s: %w", pkg.RepoURL, err)
}
u.log(fmt.Sprintf("PPA repo %s enabled successfully", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true
}
}
if len(enabledRepos) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Updating package lists...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
}
}
return nil
}
func (u *UbuntuDistribution) installAPTPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
u.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing system packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
}
func (u *UbuntuDistribution) installPPAPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
u.log(fmt.Sprintf("Installing PPA packages: %s", strings.Join(packages, ", ")))
args := []string{"apt-get", "install", "-y"}
args = append(args, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
Progress: 0.70,
Step: "Installing PPA packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.70, 0.85)
}
func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
buildDeps := make(map[string]bool)
for _, pkg := range manualPkgs {
switch pkg {
case "niri":
buildDeps["curl"] = true
buildDeps["libxkbcommon-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["libudev-dev"] = true
buildDeps["libinput-dev"] = true
buildDeps["libdisplay-info-dev"] = true
buildDeps["libpango1.0-dev"] = true
buildDeps["libcairo-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libc6-dev"] = true
buildDeps["clang"] = true
buildDeps["libseat-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["alacritty"] = true
buildDeps["fuzzel"] = true
buildDeps["libxcb-cursor-dev"] = true
case "quickshell":
buildDeps["qt6-base-dev"] = true
buildDeps["qt6-base-private-dev"] = true
buildDeps["qt6-declarative-dev"] = true
buildDeps["qt6-declarative-private-dev"] = true
buildDeps["qt6-wayland-dev"] = true
buildDeps["qt6-wayland-private-dev"] = true
buildDeps["qt6-tools-dev"] = true
buildDeps["libqt6svg6-dev"] = true
buildDeps["qt6-shadertools-dev"] = true
buildDeps["spirv-tools"] = true
buildDeps["libcli11-dev"] = true
buildDeps["libjemalloc-dev"] = true
buildDeps["libwayland-dev"] = true
buildDeps["wayland-protocols"] = true
buildDeps["libdrm-dev"] = true
buildDeps["libgbm-dev"] = true
buildDeps["libegl-dev"] = true
buildDeps["libgles2-mesa-dev"] = true
buildDeps["libgl1-mesa-dev"] = true
buildDeps["libxcb1-dev"] = true
buildDeps["libpipewire-0.3-dev"] = true
buildDeps["libpam0g-dev"] = true
case "ghostty":
buildDeps["curl"] = true
buildDeps["libgtk-4-dev"] = true
buildDeps["libadwaita-1-dev"] = true
case "matugen":
buildDeps["curl"] = true
case "cliphist":
// Go will be installed separately with PPA
}
}
for _, pkg := range manualPkgs {
switch pkg {
case "niri", "matugen":
if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "ghostty":
if err := u.installZig(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Zig: %w", err)
}
case "cliphist", "dgop":
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
}
}
}
if len(buildDeps) == 0 {
return nil
}
depList := make([]string, 0, len(buildDeps))
for dep := range buildDeps {
depList = append(depList, dep)
}
args := []string{"apt-get", "install", "-y"}
args = append(args, depList...)
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
}
func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("cargo") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.82,
Step: "Installing rustup...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install rustup",
}
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
return fmt.Errorf("failed to install rustup: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.83,
Step: "Installing stable Rust toolchain...",
IsComplete: false,
CommandInfo: "rustup install stable",
}
rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable")
if err := u.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil {
return fmt.Errorf("failed to install Rust toolchain: %w", err)
}
// Verify cargo is now available
if !u.commandExists("cargo") {
u.log("Warning: cargo not found in PATH after Rust installation, trying to source environment")
}
return nil
}
func (u *UbuntuDistribution) installZig(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("zig") {
return nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
zigUrl := "https://ziglang.org/download/0.11.0/zig-linux-x86_64-0.11.0.tar.xz"
zigTmp := filepath.Join(cacheDir, "zig.tar.xz")
downloadCmd := exec.CommandContext(ctx, "curl", "-L", zigUrl, "-o", zigTmp)
if err := u.runWithProgress(downloadCmd, progressChan, PhaseSystemPackages, 0.84, 0.85); err != nil {
return fmt.Errorf("failed to download Zig: %w", err)
}
extractCmd := ExecSudoCommand(ctx, sudoPassword,
fmt.Sprintf("tar -xf %s -C /opt/", zigTmp))
if err := u.runWithProgress(extractCmd, progressChan, PhaseSystemPackages, 0.85, 0.86); err != nil {
return fmt.Errorf("failed to extract Zig: %w", err)
}
linkCmd := ExecSudoCommand(ctx, sudoPassword,
"ln -sf /opt/zig-linux-x86_64-0.11.0/zig /usr/local/bin/zig")
return u.runWithProgress(linkCmd, progressChan, PhaseSystemPackages, 0.86, 0.87)
}
func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("go") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.87,
Step: "Adding Go PPA repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
}
addPPACmd := ExecSudoCommand(ctx, sudoPassword,
"add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.88,
Step: "Updating package lists...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get update",
}
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.89,
Step: "Installing Go...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
}
func (u *UbuntuDistribution) installGhosttyUbuntu(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
u.log("Installing Ghostty using Ubuntu installer script...")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Running Ghostty Ubuntu installer...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | sudo bash",
LogOutput: "Installing Ghostty using pre-built Ubuntu package",
}
installCmd := ExecSudoCommand(ctx, sudoPassword,
"/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)\"")
if err := u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.9); err != nil {
return fmt.Errorf("failed to install Ghostty: %w", err)
}
u.log("Ghostty installed successfully using Ubuntu installer")
return nil
}
// Override InstallManualPackages for Ubuntu to handle Ubuntu-specific installations
func (u *UbuntuDistribution) InstallManualPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
u.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", ")))
for _, pkg := range packages {
switch pkg {
case "ghostty":
if err := u.installGhosttyUbuntu(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install ghostty: %w", err)
}
default:
// Use the base ManualPackageInstaller for other packages
if err := u.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install %s: %w", pkg, err)
}
}
}
return nil
}

438
backend/internal/dms/app.go Normal file
View File

@@ -0,0 +1,438 @@
//go:build !distro_binary
package dms
import (
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateUpdate
StateUpdatePassword
StateUpdateProgress
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateGreeterMenu
StateGreeterCompositorSelect
StateGreeterPassword
StateGreeterInstalling
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
updateDeps []DependencyInfo
selectedUpdateDep int
updateToggles map[string]bool
updateProgressChan chan updateProgressMsg
updateProgress updateProgressMsg
updateLogs []string
sudoPassword string
passwordInput string
passwordError string
// Window manager states
hyprlandInstalled bool
niriInstalled bool
selectedGreeterItem int
greeterInstallChan chan greeterProgressMsg
greeterProgress greeterProgressMsg
greeterLogs []string
greeterPasswordInput string
greeterPasswordError string
greeterSudoPassword string
greeterCompositors []string
greeterSelectedComp int
greeterChosenCompositor string
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
dependencies := detector.GetInstalledComponents()
// Use the proper detection method for both window managers
hyprlandInstalled, niriInstalled, err := detector.GetWindowManagerStatus()
if err != nil {
// Fallback to false if detection fails
hyprlandInstalled = false
niriInstalled = false
}
updateToggles := make(map[string]bool)
for _, dep := range dependencies {
if dep.Name == "dms (DankMaterialShell)" && dep.Status == deps.StatusNeedsUpdate {
updateToggles[dep.Name] = true
break
}
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
updateToggles: updateToggles,
updateDeps: dependencies,
updateProgressChan: make(chan updateProgressMsg, 100),
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
greeterInstallChan: make(chan greeterProgressMsg, 100),
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{
{Label: "Update", Action: StateUpdate},
}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
// Greeter management
items = append(items, MenuItem{Label: "Greeter", Action: StateGreeterMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
// Check for both -c and -p flag patterns since quickshell can be started either way
// -c dms: config name mode
// -p <path>/dms: path mode (used when installed via system packages)
cmd := exec.Command("pgrep", "-f", "qs.*dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case shellStartedMsg:
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
return m, nil
case updateProgressMsg:
m.updateProgress = msg
if msg.logOutput != "" {
m.updateLogs = append(m.updateLogs, msg.logOutput)
}
return m, m.waitForProgress()
case updateCompleteMsg:
m.updateProgress.complete = true
m.updateProgress.err = msg.err
m.dependencies = m.detector.GetInstalledComponents()
m.updateDeps = m.dependencies
m.menuItems = m.buildMenuItems()
// Restart shell if update was successful and shell is running
if msg.err == nil && m.isShellRunning() {
restartShell()
}
return m, nil
case greeterProgressMsg:
m.greeterProgress = msg
if msg.logOutput != "" {
m.greeterLogs = append(m.greeterLogs, msg.logOutput)
}
return m, m.waitForGreeterProgress()
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case greeterPasswordValidMsg:
if msg.valid {
m.greeterSudoPassword = msg.password
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
m.state = StateGreeterInstalling
m.greeterProgress = greeterProgressMsg{step: "Starting greeter installation..."}
m.greeterLogs = []string{}
return m, tea.Batch(m.performGreeterInstall(), m.waitForGreeterProgress())
} else {
m.greeterPasswordError = "Incorrect password. Please try again."
m.greeterPasswordInput = ""
}
return m, nil
case passwordValidMsg:
if msg.valid {
m.sudoPassword = msg.password
m.passwordInput = ""
m.passwordError = ""
m.state = StateUpdateProgress
m.updateProgress = updateProgressMsg{progress: 0.0, step: "Starting update..."}
m.updateLogs = []string{}
return m, tea.Batch(m.performUpdate(), m.waitForProgress())
} else {
m.passwordError = "Incorrect password. Please try again."
m.passwordInput = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateUpdate:
return m.updateUpdateView(msg)
case StateUpdatePassword:
return m.updatePasswordView(msg)
case StateUpdateProgress:
return m.updateProgressView(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateGreeterMenu:
return m.updateGreeterMenu(msg)
case StateGreeterCompositorSelect:
return m.updateGreeterCompositorSelect(msg)
case StateGreeterPassword:
return m.updateGreeterPasswordView(msg)
case StateGreeterInstalling:
return m.updateGreeterInstalling(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
type updateProgressMsg struct {
progress float64
step string
complete bool
err error
logOutput string
}
type updateCompleteMsg struct {
err error
}
type passwordValidMsg struct {
password string
valid bool
}
type greeterProgressMsg struct {
step string
complete bool
err error
logOutput string
}
type greeterPasswordValidMsg struct {
password string
valid bool
}
func (m Model) waitForProgress() tea.Cmd {
return func() tea.Msg {
return <-m.updateProgressChan
}
}
func (m Model) waitForGreeterProgress() tea.Cmd {
return func() tea.Msg {
return <-m.greeterInstallChan
}
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateUpdate:
return m.renderUpdateView()
case StateUpdatePassword:
return m.renderPasswordView()
case StateUpdateProgress:
return m.renderProgressView()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateGreeterMenu:
return m.renderGreeterMenu()
case StateGreeterCompositorSelect:
return m.renderGreeterCompositorSelect()
case StateGreeterPassword:
return m.renderGreeterPasswordView()
case StateGreeterInstalling:
return m.renderGreeterInstalling()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

View File

@@ -0,0 +1,261 @@
//go:build distro_binary
package dms
import (
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
// Window manager states
hyprlandInstalled bool
niriInstalled bool
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
dependencies := detector.GetInstalledComponents()
// Use the proper detection method for both window managers
hyprlandInstalled, niriInstalled, err := detector.GetWindowManagerStatus()
if err != nil {
// Fallback to false if detection fails
hyprlandInstalled = false
niriInstalled = false
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
cmd := exec.Command("pgrep", "-f", "qs -c dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

View File

@@ -0,0 +1,167 @@
package dms
import (
"context"
"os"
"os/exec"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/config"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
)
type Detector struct {
homeDir string
distribution distros.Distribution
}
func (d *Detector) GetDistribution() distros.Distribution {
return d.distribution
}
func NewDetector() (*Detector, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
logChan := make(chan string, 100)
go func() {
for range logChan {
}
}()
osInfo, err := distros.GetOSInfo()
if err != nil {
return nil, err
}
dist, err := distros.NewDistribution(osInfo.Distribution.ID, logChan)
if err != nil {
return nil, err
}
return &Detector{
homeDir: homeDir,
distribution: dist,
}, nil
}
func (d *Detector) IsDMSInstalled() bool {
_, err := config.LocateDMSConfig()
return err == nil
}
func (d *Detector) GetDependencyStatus() ([]deps.Dependency, error) {
hyprlandDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerHyprland)
if err != nil {
return nil, err
}
niriDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerNiri)
if err != nil {
return nil, err
}
// Combine dependencies and deduplicate
depMap := make(map[string]deps.Dependency)
for _, dep := range hyprlandDeps {
depMap[dep.Name] = dep
}
for _, dep := range niriDeps {
// If dependency already exists, keep the one that's installed or needs update
if existing, exists := depMap[dep.Name]; exists {
if dep.Status > existing.Status {
depMap[dep.Name] = dep
}
} else {
depMap[dep.Name] = dep
}
}
// Convert map back to slice
var allDeps []deps.Dependency
for _, dep := range depMap {
allDeps = append(allDeps, dep)
}
return allDeps, nil
}
func (d *Detector) GetWindowManagerStatus() (bool, bool, error) {
// Reuse the existing command detection logic from BaseDistribution
// Since all distros embed BaseDistribution, we can access it via interface
type CommandChecker interface {
CommandExists(string) bool
}
checker, ok := d.distribution.(CommandChecker)
if !ok {
// Fallback to direct command check if interface not available
hyprlandInstalled := d.commandExists("hyprland") || d.commandExists("Hyprland")
niriInstalled := d.commandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
hyprlandInstalled := checker.CommandExists("hyprland") || checker.CommandExists("Hyprland")
niriInstalled := checker.CommandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
func (d *Detector) commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func (d *Detector) GetInstalledComponents() []DependencyInfo {
dependencies, err := d.GetDependencyStatus()
if err != nil {
return []DependencyInfo{}
}
isNixOS := d.isNixOS()
var components []DependencyInfo
for _, dep := range dependencies {
// On NixOS, filter out the window managers themselves but keep their components
if isNixOS && (dep.Name == "hyprland" || dep.Name == "niri") {
continue
}
components = append(components, DependencyInfo{
Name: dep.Name,
Status: dep.Status,
Description: dep.Description,
Required: dep.Required,
})
}
return components
}
func (d *Detector) isNixOS() bool {
_, err := os.Stat("/etc/nixos")
if err == nil {
return true
}
// Alternative check
if _, err := os.Stat("/nix/store"); err == nil {
// Also check for nixos-version command
if d.commandExists("nixos-version") {
return true
}
}
return false
}
type DependencyInfo struct {
Name string
Status deps.DependencyStatus
Description string
Required bool
}

View File

@@ -0,0 +1,54 @@
package dms
import (
"os/exec"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateShellView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
default:
return m, tea.Quit
}
return m, nil
}
func (m Model) updateAboutView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
if msg.String() == "esc" {
m.state = StateMainMenu
} else {
return m, tea.Quit
}
}
return m, nil
}
func terminateShell() {
patterns := []string{"dms run", "qs -c dms"}
for _, pattern := range patterns {
cmd := exec.Command("pkill", "-f", pattern)
cmd.Run()
}
}
func startShellDaemon() {
cmd := exec.Command("dms", "run", "-d")
if err := cmd.Start(); err != nil {
log.Errorf("Error starting daemon: %v", err)
}
}
func restartShell() {
terminateShell()
time.Sleep(500 * time.Millisecond)
startShellDaemon()
}

View File

@@ -0,0 +1,392 @@
//go:build !distro_binary
package dms
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/greeter"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateUpdateView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
filteredDeps := m.getFilteredDeps()
maxIndex := len(filteredDeps) - 1
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedUpdateDep > 0 {
m.selectedUpdateDep--
}
case "down", "j":
if m.selectedUpdateDep < maxIndex {
m.selectedUpdateDep++
}
case " ":
if dep := m.getDepAtVisualIndex(m.selectedUpdateDep); dep != nil {
m.updateToggles[dep.Name] = !m.updateToggles[dep.Name]
}
case "enter":
hasSelected := false
for _, toggle := range m.updateToggles {
if toggle {
hasSelected = true
break
}
}
if !hasSelected {
m.state = StateMainMenu
return m, nil
}
m.state = StateUpdatePassword
m.passwordInput = ""
m.passwordError = ""
return m, nil
}
return m, nil
}
func (m Model) updatePasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateUpdate
m.passwordInput = ""
m.passwordError = ""
return m, nil
case "enter":
if m.passwordInput == "" {
return m, nil
}
return m, m.validatePassword(m.passwordInput)
case "backspace":
if len(m.passwordInput) > 0 {
m.passwordInput = m.passwordInput[:len(m.passwordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.passwordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateProgressView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.updateProgress.complete {
m.state = StateMainMenu
m.updateProgress = updateProgressMsg{}
m.updateLogs = []string{}
}
}
return m, nil
}
func (m Model) validatePassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: password, valid: true}
}
}
func (m Model) performUpdate() tea.Cmd {
var depsToUpdate []deps.Dependency
for _, depInfo := range m.updateDeps {
if m.updateToggles[depInfo.Name] {
depsToUpdate = append(depsToUpdate, deps.Dependency{
Name: depInfo.Name,
Status: depInfo.Status,
Description: depInfo.Description,
Required: depInfo.Required,
})
}
}
if len(depsToUpdate) == 0 {
return func() tea.Msg {
return updateCompleteMsg{err: nil}
}
}
wm := deps.WindowManagerHyprland
if m.niriInstalled {
wm = deps.WindowManagerNiri
}
sudoPassword := m.sudoPassword
reinstallFlags := make(map[string]bool)
for name, toggled := range m.updateToggles {
if toggled {
reinstallFlags[name] = true
}
}
distribution := m.detector.GetDistribution()
progressChan := m.updateProgressChan
return func() tea.Msg {
installerChan := make(chan distros.InstallProgressMsg, 100)
go func() {
ctx := context.Background()
disabledFlags := make(map[string]bool)
err := distribution.InstallPackages(ctx, depsToUpdate, wm, sudoPassword, reinstallFlags, disabledFlags, false, installerChan)
close(installerChan)
if err != nil {
progressChan <- updateProgressMsg{complete: true, err: err}
} else {
progressChan <- updateProgressMsg{complete: true}
}
}()
go func() {
for msg := range installerChan {
progressChan <- updateProgressMsg{
progress: msg.Progress,
step: msg.Step,
complete: msg.IsComplete,
err: msg.Error,
logOutput: msg.LogOutput,
}
}
}()
return nil
}
}
func (m Model) updateGreeterMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
greeterMenuItems := []string{"Install Greeter"}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedGreeterItem > 0 {
m.selectedGreeterItem--
}
case "down", "j":
if m.selectedGreeterItem < len(greeterMenuItems)-1 {
m.selectedGreeterItem++
}
case "enter", " ":
if m.selectedGreeterItem == 0 {
compositors := greeter.DetectCompositors()
if len(compositors) == 0 {
return m, nil
}
m.greeterCompositors = compositors
if len(compositors) > 1 {
m.state = StateGreeterCompositorSelect
m.greeterSelectedComp = 0
return m, nil
} else {
m.greeterChosenCompositor = compositors[0]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
}
}
return m, nil
}
func (m Model) updateGreeterCompositorSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
return m, nil
case "up", "k":
if m.greeterSelectedComp > 0 {
m.greeterSelectedComp--
}
case "down", "j":
if m.greeterSelectedComp < len(m.greeterCompositors)-1 {
m.greeterSelectedComp++
}
case "enter", " ":
m.greeterChosenCompositor = m.greeterCompositors[m.greeterSelectedComp]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
return m, nil
}
func (m Model) updateGreeterPasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
case "enter":
if m.greeterPasswordInput == "" {
return m, nil
}
return m, m.validateGreeterPassword(m.greeterPasswordInput)
case "backspace":
if len(m.greeterPasswordInput) > 0 {
m.greeterPasswordInput = m.greeterPasswordInput[:len(m.greeterPasswordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.greeterPasswordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateGreeterInstalling(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.greeterProgress.complete {
m.state = StateMainMenu
m.greeterProgress = greeterProgressMsg{}
m.greeterLogs = []string{}
}
}
return m, nil
}
func (m Model) performGreeterInstall() tea.Cmd {
progressChan := m.greeterInstallChan
sudoPassword := m.greeterSudoPassword
compositor := m.greeterChosenCompositor
return func() tea.Msg {
go func() {
logFunc := func(msg string) {
progressChan <- greeterProgressMsg{step: msg, logOutput: msg}
}
progressChan <- greeterProgressMsg{step: "Checking greetd installation..."}
if err := performGreeterInstallSteps(progressChan, logFunc, sudoPassword, compositor); err != nil {
progressChan <- greeterProgressMsg{step: "Installation failed", complete: true, err: err}
return
}
progressChan <- greeterProgressMsg{step: "Installation complete", complete: true}
}()
return nil
}
}
func (m Model) validateGreeterPassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return greeterPasswordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: password, valid: true}
}
}
func performGreeterInstallSteps(progressChan chan greeterProgressMsg, logFunc func(string), sudoPassword string, compositor string) error {
if err := greeter.EnsureGreetdInstalled(logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Detecting DMS installation..."}
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
logFunc(fmt.Sprintf("✓ Found DMS at: %s", dmsPath))
logFunc(fmt.Sprintf("✓ Selected compositor: %s", compositor))
progressChan <- greeterProgressMsg{step: "Copying greeter files..."}
if err := greeter.CopyGreeterFiles(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Configuring greetd..."}
if err := greeter.ConfigureGreetd(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Synchronizing DMS configurations..."}
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, sudoPassword); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,61 @@
//go:build !distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateUpdate:
m.state = StateUpdate
m.selectedUpdateDep = 0
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateGreeterMenu:
m.state = StateGreeterMenu
m.selectedGreeterItem = 0
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -0,0 +1,55 @@
//go:build distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -0,0 +1,339 @@
package dms
import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updatePluginsMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedPluginsMenuItem > 0 {
m.selectedPluginsMenuItem--
}
case "down", "j":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems)-1 {
m.selectedPluginsMenuItem++
}
case "enter", " ":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems) {
selectedAction := m.pluginsMenuItems[m.selectedPluginsMenuItem].Action
switch selectedAction {
case StatePluginsBrowse:
m.state = StatePluginsBrowse
m.pluginsLoading = true
m.pluginsError = ""
m.pluginsList = nil
return m, loadPlugins
case StatePluginsInstalled:
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
m.installedPluginsList = nil
return m, loadInstalledPlugins
}
}
}
return m, nil
}
func (m Model) updatePluginsBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "up", "k":
if m.selectedPluginIndex > 0 {
m.selectedPluginIndex--
}
case "down", "j":
if m.selectedPluginIndex < len(m.filteredPluginsList)-1 {
m.selectedPluginIndex++
}
case "enter", " ":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
m.state = StatePluginDetail
}
case "/":
m.state = StatePluginSearch
m.pluginSearchQuery = ""
}
return m, nil
}
func (m Model) updatePluginDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
case "i":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
plugin := m.filteredPluginsList[m.selectedPluginIndex]
installed := m.pluginInstallStatus[plugin.Name]
if !installed {
return m, installPlugin(plugin)
}
}
}
return m, nil
}
func (m Model) updatePluginSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "enter":
m.state = StatePluginsBrowse
m.filterPlugins()
case "backspace":
if len(m.pluginSearchQuery) > 0 {
m.pluginSearchQuery = m.pluginSearchQuery[:len(m.pluginSearchQuery)-1]
}
default:
if len(msg.String()) == 1 {
m.pluginSearchQuery += msg.String()
}
}
return m, nil
}
func (m *Model) filterPlugins() {
if m.pluginSearchQuery == "" {
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
return
}
rawPlugins := make([]plugins.Plugin, len(m.pluginsList))
for i, p := range m.pluginsList {
rawPlugins[i] = plugins.Plugin{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
}
}
searchResults := plugins.FuzzySearch(m.pluginSearchQuery, rawPlugins)
searchResults = plugins.SortByFirstParty(searchResults)
filtered := make([]pluginInfo, len(searchResults))
for i, p := range searchResults {
filtered[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = filtered
m.selectedPluginIndex = 0
}
type pluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
func loadPlugins() tea.Msg {
registry, err := plugins.NewRegistry()
if err != nil {
return pluginsLoadedMsg{err: err}
}
pluginList, err := registry.List()
if err != nil {
return pluginsLoadedMsg{err: err}
}
return pluginsLoadedMsg{plugins: pluginList}
}
func (m *Model) updatePluginInstallStatus() {
manager, err := plugins.NewManager()
if err != nil {
return
}
for _, plugin := range m.pluginsList {
p := plugins.Plugin{ID: plugin.ID}
installed, err := manager.IsInstalled(p)
if err == nil {
m.pluginInstallStatus[plugin.Name] = installed
}
}
}
func (m Model) updatePluginsInstalled(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
case "up", "k":
if m.selectedInstalledIndex > 0 {
m.selectedInstalledIndex--
}
case "down", "j":
if m.selectedInstalledIndex < len(m.installedPluginsList)-1 {
m.selectedInstalledIndex++
}
case "enter", " ":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
m.state = StatePluginInstalledDetail
}
}
return m, nil
}
func (m Model) updatePluginInstalledDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsInstalled
case "u":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, uninstallPlugin(plugin)
}
}
return m, nil
}
type installedPluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
type pluginUninstalledMsg struct {
pluginName string
err error
}
type pluginInstalledMsg struct {
pluginName string
err error
}
func loadInstalledPlugins() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
registry, err := plugins.NewRegistry()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
installedNames, err := manager.ListInstalled()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
allPlugins, err := registry.List()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
var installed []plugins.Plugin
for _, id := range installedNames {
for _, p := range allPlugins {
if p.ID == id {
installed = append(installed, p)
break
}
}
}
installed = plugins.SortByFirstParty(installed)
return installedPluginsLoadedMsg{plugins: installed}
}
func installPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Install(p); err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginInstalledMsg{pluginName: plugin.Name}
}
}
func uninstallPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Uninstall(p); err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginUninstalledMsg{pluginName: plugin.Name}
}
}

View File

@@ -0,0 +1,367 @@
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderPluginsMenu() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Plugins"))
b.WriteString("\n\n")
for i, item := range m.pluginsMenuItems {
if i == m.selectedPluginsMenuItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Select | Esc: Back | q: Quit"))
return b.String()
}
func (m Model) renderPluginsBrowse() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Browse Plugins"))
b.WriteString("\n\n")
if m.pluginsLoading {
b.WriteString(normalStyle.Render("Fetching plugins from registry..."))
} else if m.pluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.pluginsError)))
} else if len(m.filteredPluginsList) == 0 {
if m.pluginSearchQuery != "" {
b.WriteString(normalStyle.Render(fmt.Sprintf("No plugins match '%s'", m.pluginSearchQuery)))
} else {
b.WriteString(normalStyle.Render("No plugins found in registry."))
}
} else {
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
for i, plugin := range m.filteredPluginsList {
installed := m.pluginInstallStatus[plugin.Name]
installMarker := ""
if installed {
installMarker = " [Installed]"
}
if i == m.selectedPluginIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.pluginsLoading || m.pluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: View/Install | /: Search | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.selectedPluginIndex >= len(m.filteredPluginsList) {
return "No plugin selected"
}
plugin := m.filteredPluginsList[m.selectedPluginIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
installed := m.pluginInstallStatus[plugin.Name]
if installed {
b.WriteString(labelStyle.Render("Status: "))
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(installedStyle.Render("Installed"))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if installed {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("i: Install | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginSearch() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(titleStyle.Render("Search Plugins"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Query: "))
b.WriteString(titleStyle.Render(m.pluginSearchQuery + "▌"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Enter: Search | Esc: Cancel"))
return b.String()
}
func (m Model) renderPluginsInstalled() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Installed Plugins"))
b.WriteString("\n\n")
if m.installedPluginsLoading {
b.WriteString(normalStyle.Render("Loading installed plugins..."))
} else if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
} else if len(m.installedPluginsList) == 0 {
b.WriteString(normalStyle.Render("No plugins installed."))
} else {
for i, plugin := range m.installedPluginsList {
if i == m.selectedInstalledIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.installedPluginsLoading || m.installedPluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Details | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginInstalledDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
if m.selectedInstalledIndex >= len(m.installedPluginsList) {
return "No plugin selected"
}
plugin := m.installedPluginsList[m.selectedInstalledIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("u: Uninstall | Esc: Back | q: Quit"))
return b.String()
}
func wrapText(text string, width int) string {
words := strings.Fields(text)
if len(words) == 0 {
return text
}
var lines []string
currentLine := words[0]
for _, word := range words[1:] {
if len(currentLine)+1+len(word) <= width {
currentLine += " " + word
} else {
lines = append(lines, currentLine)
currentLine = word
}
}
lines = append(lines, currentLine)
return strings.Join(lines, "\n")
}

View File

@@ -0,0 +1,149 @@
package dms
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/tui"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderMainMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("dms"))
b.WriteString("\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range m.menuItems {
if i == m.selectedItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, q/Esc: Exit"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderShellView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Shell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Opening interactive shell..."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("This will launch a shell with DMS environment loaded."))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Press any key to launch shell, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderAboutView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("About DankMaterialShell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render(fmt.Sprintf("DMS Management Interface %s", m.version)))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("DankMaterialShell is a comprehensive desktop environment"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("built around Quickshell, providing a modern Material Design"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("experience for Wayland compositors."))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Components:"))
b.WriteString("\n")
for _, dep := range m.dependencies {
status := "✗"
if dep.Status == 1 {
status = "✓"
}
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s %s", status, dep.Name)))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Esc: Back to main menu"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderBanner() string {
theme := tui.TerminalTheme()
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true).
MarginBottom(1)
return titleStyle.Render(logo)
}

View File

@@ -0,0 +1,529 @@
//go:build !distro_binary
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderUpdateView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Update Dependencies"))
b.WriteString("\n")
if len(m.updateDeps) == 0 {
b.WriteString("Loading dependencies...\n")
return b.String()
}
categories := m.categorizeDependencies()
currentIndex := 0
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if !exists || len(deps) == 0 {
continue
}
categoryStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7060ac")).
Bold(true).
MarginTop(1)
b.WriteString(categoryStyle.Render(category + ":"))
b.WriteString("\n")
for _, dep := range deps {
var statusText, icon, reinstallMarker string
var style lipgloss.Style
if m.updateToggles[dep.Name] {
reinstallMarker = "🔄 "
if dep.Status == 0 {
statusText = "Will be installed"
} else {
statusText = "Will be upgraded"
}
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
} else {
switch dep.Status {
case 1:
icon = "✓"
statusText = "Installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
case 0:
icon = "○"
statusText = "Not installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
case 2:
icon = "△"
statusText = "Needs update"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
case 3:
icon = "!"
statusText = "Needs reinstall"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
}
}
line := fmt.Sprintf("%s%s%-25s %s", reinstallMarker, icon, dep.Name, statusText)
if currentIndex == m.selectedUpdateDep {
line = "▶ " + line
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7060ac")).Bold(true)
b.WriteString(selectedStyle.Render(line))
} else {
line = " " + line
b.WriteString(style.Render(line))
}
b.WriteString("\n")
currentIndex++
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Space: Toggle, Enter: Update Selected, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Package installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.passwordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.passwordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.passwordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderProgressView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Updating Packages"))
b.WriteString("\n\n")
if !m.updateProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.updateProgress.step))
b.WriteString("\n\n")
progressBar := fmt.Sprintf("[%s%s] %.0f%%",
strings.Repeat("█", int(m.updateProgress.progress*30)),
strings.Repeat("░", 30-int(m.updateProgress.progress*30)),
m.updateProgress.progress*100)
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render(progressBar))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Live Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 8
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.updateProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Update failed: %v", m.updateProgress.err)))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Error Logs:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 15
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.updateProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Update complete!"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) getFilteredDeps() []DependencyInfo {
categories := m.categorizeDependencies()
var filtered []DependencyInfo
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if exists {
filtered = append(filtered, deps...)
}
}
return filtered
}
func (m Model) getDepAtVisualIndex(index int) *DependencyInfo {
filtered := m.getFilteredDeps()
if index >= 0 && index < len(filtered) {
return &filtered[index]
}
return nil
}
func (m Model) renderGreeterPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Greeter installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.greeterPasswordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.greeterPasswordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.greeterPasswordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterCompositorSelect() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Select Compositor"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Multiple compositors detected. Choose which one to use for the greeter:"))
b.WriteString("\n\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
for i, comp := range m.greeterCompositors {
if i == m.greeterSelectedComp {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", comp)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", comp)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Greeter Management"))
b.WriteString("\n")
greeterMenuItems := []string{"Install Greeter"}
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range greeterMenuItems {
if i == m.selectedGreeterItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterInstalling() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Installing Greeter"))
b.WriteString("\n\n")
if !m.greeterProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.greeterProgress.step))
b.WriteString("\n\n")
if len(m.greeterLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 10
startIdx := 0
if len(m.greeterLogs) > maxLines {
startIdx = len(m.greeterLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.greeterLogs); i++ {
if m.greeterLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.greeterLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.greeterProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Installation failed: %v", m.greeterProgress.err)))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.greeterProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Greeter installation complete!"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("To test the greeter, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl start greetd"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("To enable on boot, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl enable --now greetd"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) categorizeDependencies() map[string][]DependencyInfo {
categories := map[string][]DependencyInfo{
"Shell": {},
"Shared Components": {},
"Hyprland Components": {},
"Niri Components": {},
}
excludeList := map[string]bool{
"git": true,
"polkit-agent": true,
"jq": true,
"xdg-desktop-portal": true,
"xdg-desktop-portal-wlr": true,
"xdg-desktop-portal-hyprland": true,
"xdg-desktop-portal-gtk": true,
}
for _, dep := range m.updateDeps {
if excludeList[dep.Name] {
continue
}
switch dep.Name {
case "dms (DankMaterialShell)", "quickshell":
categories["Shell"] = append(categories["Shell"], dep)
case "hyprland", "grim", "slurp", "hyprctl", "grimblast":
categories["Hyprland Components"] = append(categories["Hyprland Components"], dep)
case "niri":
categories["Niri Components"] = append(categories["Niri Components"], dep)
case "kitty", "alacritty", "ghostty", "hyprpicker":
categories["Shared Components"] = append(categories["Shared Components"], dep)
default:
categories["Shared Components"] = append(categories["Shared Components"], dep)
}
}
return categories
}

View File

@@ -0,0 +1,65 @@
package errdefs
type ErrorType int
const (
ErrTypeNotLinux ErrorType = iota
ErrTypeInvalidArchitecture
ErrTypeUnsupportedDistribution
ErrTypeUnsupportedVersion
ErrTypeUpdateCancelled
ErrTypeNoUpdateNeeded
ErrTypeInvalidTemperature
ErrTypeInvalidGamma
ErrTypeInvalidLocation
ErrTypeInvalidManualTimes
ErrTypeNoWaylandDisplay
ErrTypeNoGammaControl
ErrTypeNotInitialized
ErrTypeSecretPromptCancelled
ErrTypeSecretPromptTimeout
ErrTypeSecretAgentFailed
ErrTypeGeneric
)
type CustomError struct {
Type ErrorType
Message string
}
func (e *CustomError) Error() string {
return e.Message
}
func NewCustomError(errType ErrorType, message string) error {
return &CustomError{
Type: errType,
Message: message,
}
}
const (
ErrBadCredentials = "bad-credentials"
ErrNoSuchSSID = "no-such-ssid"
ErrAssocTimeout = "assoc-timeout"
ErrDhcpTimeout = "dhcp-timeout"
ErrUserCanceled = "user-canceled"
ErrWifiDisabled = "wifi-disabled"
ErrAlreadyConnected = "already-connected"
ErrConnectionFailed = "connection-failed"
)
var (
ErrUpdateCancelled = NewCustomError(ErrTypeUpdateCancelled, "update cancelled by user")
ErrNoUpdateNeeded = NewCustomError(ErrTypeNoUpdateNeeded, "no update needed")
ErrInvalidTemperature = NewCustomError(ErrTypeInvalidTemperature, "temperature must be between 1000 and 10000")
ErrInvalidGamma = NewCustomError(ErrTypeInvalidGamma, "gamma must be between 0 and 10")
ErrInvalidLocation = NewCustomError(ErrTypeInvalidLocation, "invalid latitude/longitude")
ErrInvalidManualTimes = NewCustomError(ErrTypeInvalidManualTimes, "both sunrise and sunset must be set or neither")
ErrNoWaylandDisplay = NewCustomError(ErrTypeNoWaylandDisplay, "no wayland display available")
ErrNoGammaControl = NewCustomError(ErrTypeNoGammaControl, "compositor does not support gamma control")
ErrNotInitialized = NewCustomError(ErrTypeNotInitialized, "manager not initialized")
ErrSecretPromptCancelled = NewCustomError(ErrTypeSecretPromptCancelled, "secret prompt cancelled by user")
ErrSecretPromptTimeout = NewCustomError(ErrTypeSecretPromptTimeout, "secret prompt timed out")
ErrSecretAgentFailed = NewCustomError(ErrTypeSecretAgentFailed, "secret agent operation failed")
)

View File

@@ -0,0 +1,490 @@
package greeter
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/config"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
)
// DetectDMSPath checks for DMS installation following XDG Base Directory specification
func DetectDMSPath() (string, error) {
return config.LocateDMSConfig()
}
// DetectCompositors checks which compositors are installed
func DetectCompositors() []string {
var compositors []string
if commandExists("niri") {
compositors = append(compositors, "niri")
}
if commandExists("Hyprland") {
compositors = append(compositors, "Hyprland")
}
return compositors
}
// PromptCompositorChoice asks user to choose between compositors
func PromptCompositorChoice(compositors []string) (string, error) {
fmt.Println("\nMultiple compositors detected:")
for i, comp := range compositors {
fmt.Printf("%d) %s\n", i+1, comp)
}
reader := bufio.NewReader(os.Stdin)
fmt.Print("Choose compositor for greeter (1-2): ")
response, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(response)
switch response {
case "1":
return compositors[0], nil
case "2":
if len(compositors) > 1 {
return compositors[1], nil
}
return "", fmt.Errorf("invalid choice")
default:
return "", fmt.Errorf("invalid choice")
}
}
// EnsureGreetdInstalled checks if greetd is installed and installs it if not
func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
if commandExists("greetd") {
logFunc("✓ greetd is already installed")
return nil
}
logFunc("greetd is not installed. Installing...")
osInfo, err := distros.GetOSInfo()
if err != nil {
return fmt.Errorf("failed to detect OS: %w", err)
}
config, exists := distros.Registry[osInfo.Distribution.ID]
if !exists {
return fmt.Errorf("unsupported distribution for automatic greetd installation: %s", osInfo.Distribution.ID)
}
ctx := context.Background()
var installCmd *exec.Cmd
switch config.Family {
case distros.FamilyArch:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"pacman -S --needed --noconfirm greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd")
}
case distros.FamilyFedora:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"dnf install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd")
}
case distros.FamilySUSE:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"zypper install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd")
}
case distros.FamilyUbuntu:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
case distros.FamilyDebian:
if sudoPassword != "" {
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
"apt-get install -y greetd")
} else {
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
}
case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
default:
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
}
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install greetd: %w", err)
}
logFunc("✓ greetd installed successfully")
return nil
}
// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory
func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
// Check if dms-greeter is already in PATH
if commandExists("dms-greeter") {
logFunc("✓ dms-greeter wrapper already installed")
} else {
// Install the wrapper script
assetsDir := filepath.Join(dmsPath, "Modules", "Greetd", "assets")
wrapperSrc := filepath.Join(assetsDir, "dms-greeter")
if _, err := os.Stat(wrapperSrc); os.IsNotExist(err) {
return fmt.Errorf("dms-greeter wrapper not found at %s", wrapperSrc)
}
wrapperDst := "/usr/local/bin/dms-greeter"
if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err)
}
logFunc(fmt.Sprintf("✓ Installed dms-greeter wrapper to %s", wrapperDst))
if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil {
return fmt.Errorf("failed to make wrapper executable: %w", err)
}
// Set SELinux context on Fedora and openSUSE
osInfo, err := distros.GetOSInfo()
if err == nil {
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) {
if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err))
} else {
logFunc("✓ Set SELinux fcontext for dms-greeter")
}
if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err))
} else {
logFunc("✓ Restored SELinux context for dms-greeter")
}
}
}
}
// Create cache directory with proper permissions
cacheDir := "/var/cache/dms-greeter"
if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := runSudoCmd(sudoPassword, "chown", "greeter:greeter", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory owner: %w", err)
}
if err := runSudoCmd(sudoPassword, "chmod", "750", cacheDir); err != nil {
return fmt.Errorf("failed to set cache directory permissions: %w", err)
}
logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: greeter:greeter, permissions: 750)", cacheDir))
return nil
}
// SetupParentDirectoryACLs sets ACLs on parent directories to allow traversal
func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
if !commandExists("setfacl") {
logFunc("⚠ Warning: setfacl command not found. ACL support may not be available on this filesystem.")
logFunc(" If theme sync doesn't work, you may need to install acl package:")
logFunc(" - Fedora/RHEL: sudo dnf install acl")
logFunc(" - Debian/Ubuntu: sudo apt-get install acl")
logFunc(" - Arch: sudo pacman -S acl")
return nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
parentDirs := []struct {
path string
desc string
}{
{homeDir, "home directory"},
{filepath.Join(homeDir, ".config"), ".config directory"},
{filepath.Join(homeDir, ".local"), ".local directory"},
{filepath.Join(homeDir, ".cache"), ".cache directory"},
{filepath.Join(homeDir, ".local", "state"), ".local/state directory"},
}
logFunc("\nSetting up parent directory ACLs for greeter user access...")
for _, dir := range parentDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.desc, err))
continue
}
}
// Set ACL to allow greeter user execute (traverse) permission
if err := runSudoCmd(sudoPassword, "setfacl", "-m", "u:greeter:x", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m u:greeter:x %s", dir.path))
continue
}
logFunc(fmt.Sprintf("✓ Set ACL on %s", dir.desc))
}
return nil
}
func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return fmt.Errorf("failed to determine current user")
}
// Check if user is already in greeter group
groupsCmd := exec.Command("groups", currentUser)
groupsOutput, err := groupsCmd.Output()
if err == nil && strings.Contains(string(groupsOutput), "greeter") {
logFunc(fmt.Sprintf("✓ %s is already in greeter group", currentUser))
} else {
// Add current user to greeter group for file access permissions
if err := runSudoCmd(sudoPassword, "usermod", "-aG", "greeter", currentUser); err != nil {
return fmt.Errorf("failed to add %s to greeter group: %w", currentUser, err)
}
logFunc(fmt.Sprintf("✓ Added %s to greeter group (logout/login required for changes to take effect)", currentUser))
}
configDirs := []struct {
path string
desc string
}{
{filepath.Join(homeDir, ".config", "DankMaterialShell"), "DankMaterialShell config"},
{filepath.Join(homeDir, ".local", "state", "DankMaterialShell"), "DankMaterialShell state"},
{filepath.Join(homeDir, ".cache", "quickshell"), "quickshell cache"},
{filepath.Join(homeDir, ".config", "quickshell"), "quickshell config"},
}
for _, dir := range configDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.path, err))
continue
}
}
if err := runSudoCmd(sudoPassword, "chgrp", "-R", "greeter", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
continue
}
if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err))
continue
}
logFunc(fmt.Sprintf("✓ Set group permissions for %s", dir.desc))
}
// Set up ACLs on parent directories to allow greeter user traversal
if err := SetupParentDirectoryACLs(logFunc, sudoPassword); err != nil {
return fmt.Errorf("failed to setup parent directory ACLs: %w", err)
}
return nil
}
func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
cacheDir := "/var/cache/dms-greeter"
symlinks := []struct {
source string
target string
desc string
}{
{
source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"),
target: filepath.Join(cacheDir, "settings.json"),
desc: "core settings (theme, clock formats, etc)",
},
{
source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"),
target: filepath.Join(cacheDir, "session.json"),
desc: "state (wallpaper configuration)",
},
{
source: filepath.Join(homeDir, ".cache", "quickshell", "dankshell", "dms-colors.json"),
target: filepath.Join(cacheDir, "colors.json"),
desc: "wallpaper based theming",
},
}
for _, link := range symlinks {
sourceDir := filepath.Dir(link.source)
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
if err := os.MkdirAll(sourceDir, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create directory %s: %v", sourceDir, err))
continue
}
}
if _, err := os.Stat(link.source); os.IsNotExist(err) {
if err := os.WriteFile(link.source, []byte("{}"), 0644); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", link.source, err))
continue
}
}
runSudoCmd(sudoPassword, "rm", "-f", link.target)
if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to create symlink for %s: %v", link.desc, err))
continue
}
logFunc(fmt.Sprintf("✓ Synced %s", link.desc))
}
return nil
}
func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
configPath := "/etc/greetd/config.toml"
if _, err := os.Stat(configPath); err == nil {
backupPath := configPath + ".backup"
if err := runSudoCmd(sudoPassword, "cp", configPath, backupPath); err != nil {
return fmt.Errorf("failed to backup config: %w", err)
}
logFunc(fmt.Sprintf("✓ Backed up existing config to %s", backupPath))
}
var configContent string
if data, err := os.ReadFile(configPath); err == nil {
configContent = string(data)
} else {
configContent = `[terminal]
vt = 1
[default_session]
user = "greeter"
`
}
lines := strings.Split(configContent, "\n")
var newLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
newLines = append(newLines, `user = "greeter"`)
} else {
newLines = append(newLines, line)
}
}
}
// Determine wrapper command path
wrapperCmd := "dms-greeter"
if !commandExists("dms-greeter") {
wrapperCmd = "/usr/local/bin/dms-greeter"
}
// Build command based on compositor and dms path
compositorLower := strings.ToLower(compositor)
command := fmt.Sprintf(`command = "%s --command %s -p %s"`, wrapperCmd, compositorLower, dmsPath)
var finalLines []string
inDefaultSession := false
commandAdded := false
for _, line := range newLines {
finalLines = append(finalLines, line)
trimmed := strings.TrimSpace(line)
if trimmed == "[default_session]" {
inDefaultSession = true
}
if inDefaultSession && !commandAdded && trimmed != "" && !strings.HasPrefix(trimmed, "[") {
if !strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "user") {
finalLines = append(finalLines, command)
commandAdded = true
}
}
}
if !commandAdded {
finalLines = append(finalLines, command)
}
newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err)
}
if err := runSudoCmd(sudoPassword, "mv", tmpFile, configPath); err != nil {
return fmt.Errorf("failed to move config to /etc/greetd: %w", err)
}
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: greeter, command: %s --command %s -p %s)", wrapperCmd, compositorLower, dmsPath))
return nil
}
func runSudoCmd(sudoPassword string, command string, args ...string) error {
var cmd *exec.Cmd
if sudoPassword != "" {
fullArgs := append([]string{command}, args...)
quotedArgs := make([]string, len(fullArgs))
for i, arg := range fullArgs {
quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
cmdStr := strings.Join(quotedArgs, " ")
cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr)
} else {
cmd = exec.Command("sudo", append([]string{command}, args...)...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}

View File

@@ -0,0 +1,331 @@
package hyprland
import (
"os"
"path/filepath"
"regexp"
"strings"
)
const (
TitleRegex = "#+!"
HideComment = "[hidden]"
CommentBindPattern = "#/#"
)
var ModSeparators = []rune{'+', ' '}
type KeyBinding struct {
Mods []string `json:"mods"`
Key string `json:"key"`
Dispatcher string `json:"dispatcher"`
Params string `json:"params"`
Comment string `json:"comment"`
}
type Section struct {
Children []Section `json:"children"`
Keybinds []KeyBinding `json:"keybinds"`
Name string `json:"name"`
}
type Parser struct {
contentLines []string
readingLine int
}
func NewParser() *Parser {
return &Parser{
contentLines: []string{},
readingLine: 0,
}
}
func (p *Parser) ReadContent(directory string) error {
expandedDir := os.ExpandEnv(directory)
expandedDir = filepath.Clean(expandedDir)
if strings.HasPrefix(expandedDir, "~") {
home, err := os.UserHomeDir()
if err != nil {
return err
}
expandedDir = filepath.Join(home, expandedDir[1:])
}
info, err := os.Stat(expandedDir)
if err != nil {
return err
}
if !info.IsDir() {
return os.ErrNotExist
}
confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf"))
if err != nil {
return err
}
if len(confFiles) == 0 {
return os.ErrNotExist
}
var combinedContent []string
for _, confFile := range confFiles {
if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() {
data, err := os.ReadFile(confFile)
if err == nil {
combinedContent = append(combinedContent, string(data))
}
}
}
if len(combinedContent) == 0 {
return os.ErrNotExist
}
fullContent := strings.Join(combinedContent, "\n")
p.contentLines = strings.Split(fullContent, "\n")
return nil
}
func autogenerateComment(dispatcher, params string) string {
switch dispatcher {
case "resizewindow":
return "Resize window"
case "movewindow":
if params == "" {
return "Move window"
}
dirMap := map[string]string{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}
if dir, ok := dirMap[params]; ok {
return "move in " + dir + " direction"
}
return "move in null direction"
case "pin":
return "pin (show on all workspaces)"
case "splitratio":
return "Window split ratio " + params
case "togglefloating":
return "Float/unfloat window"
case "resizeactive":
return "Resize window by " + params
case "killactive":
return "Close window"
case "fullscreen":
fsMap := map[string]string{
"0": "fullscreen",
"1": "maximization",
"2": "fullscreen on Hyprland's side",
}
if fs, ok := fsMap[params]; ok {
return "Toggle " + fs
}
return "Toggle null"
case "fakefullscreen":
return "Toggle fake fullscreen"
case "workspace":
switch params {
case "+1":
return "focus right"
case "-1":
return "focus left"
}
return "focus workspace " + params
case "movefocus":
dirMap := map[string]string{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}
if dir, ok := dirMap[params]; ok {
return "move focus " + dir
}
return "move focus null"
case "swapwindow":
dirMap := map[string]string{
"l": "left",
"r": "right",
"u": "up",
"d": "down",
}
if dir, ok := dirMap[params]; ok {
return "swap in " + dir + " direction"
}
return "swap in null direction"
case "movetoworkspace":
switch params {
case "+1":
return "move to right workspace (non-silent)"
case "-1":
return "move to left workspace (non-silent)"
}
return "move to workspace " + params + " (non-silent)"
case "movetoworkspacesilent":
switch params {
case "+1":
return "move to right workspace"
case "-1":
return "move to right workspace"
}
return "move to workspace " + params
case "togglespecialworkspace":
return "toggle special"
case "exec":
return params
default:
return ""
}
}
func (p *Parser) getKeybindAtLine(lineNumber int) *KeyBinding {
line := p.contentLines[lineNumber]
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return nil
}
keys := parts[1]
keyParts := strings.SplitN(keys, "#", 2)
keys = keyParts[0]
var comment string
if len(keyParts) > 1 {
comment = strings.TrimSpace(keyParts[1])
}
keyFields := strings.SplitN(keys, ",", 5)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
dispatcher := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
paramParts := keyFields[3:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
if comment != "" {
if strings.HasPrefix(comment, HideComment) {
return nil
}
} else {
comment = autogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
return &KeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
}
}
func (p *Parser) getBindsRecursive(currentContent *Section, scope int) *Section {
titleRegex := regexp.MustCompile(TitleRegex)
for p.readingLine < len(p.contentLines) {
line := p.contentLines[p.readingLine]
loc := titleRegex.FindStringIndex(line)
if loc != nil && loc[0] == 0 {
headingScope := strings.Index(line, "!")
if headingScope <= scope {
p.readingLine--
return currentContent
}
sectionName := strings.TrimSpace(line[headingScope+1:])
p.readingLine++
childSection := &Section{
Children: []Section{},
Keybinds: []KeyBinding{},
Name: sectionName,
}
result := p.getBindsRecursive(childSection, headingScope)
currentContent.Children = append(currentContent.Children, *result)
} else if strings.HasPrefix(line, CommentBindPattern) {
keybind := p.getKeybindAtLine(p.readingLine)
if keybind != nil {
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
}
} else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") {
} else {
keybind := p.getKeybindAtLine(p.readingLine)
if keybind != nil {
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
}
}
p.readingLine++
}
return currentContent
}
func (p *Parser) ParseKeys() *Section {
p.readingLine = 0
rootSection := &Section{
Children: []Section{},
Keybinds: []KeyBinding{},
Name: "",
}
return p.getBindsRecursive(rootSection, 0)
}
func ParseKeys(path string) (*Section, error) {
parser := NewParser()
if err := parser.ReadContent(path); err != nil {
return nil, err
}
return parser.ParseKeys(), nil
}

View File

@@ -0,0 +1,396 @@
package hyprland
import (
"os"
"path/filepath"
"testing"
)
func TestAutogenerateComment(t *testing.T) {
tests := []struct {
dispatcher string
params string
expected string
}{
{"resizewindow", "", "Resize window"},
{"movewindow", "", "Move window"},
{"movewindow", "l", "move in left direction"},
{"movewindow", "r", "move in right direction"},
{"movewindow", "u", "move in up direction"},
{"movewindow", "d", "move in down direction"},
{"pin", "", "pin (show on all workspaces)"},
{"splitratio", "0.5", "Window split ratio 0.5"},
{"togglefloating", "", "Float/unfloat window"},
{"resizeactive", "10 20", "Resize window by 10 20"},
{"killactive", "", "Close window"},
{"fullscreen", "0", "Toggle fullscreen"},
{"fullscreen", "1", "Toggle maximization"},
{"fullscreen", "2", "Toggle fullscreen on Hyprland's side"},
{"fakefullscreen", "", "Toggle fake fullscreen"},
{"workspace", "+1", "focus right"},
{"workspace", "-1", "focus left"},
{"workspace", "5", "focus workspace 5"},
{"movefocus", "l", "move focus left"},
{"movefocus", "r", "move focus right"},
{"movefocus", "u", "move focus up"},
{"movefocus", "d", "move focus down"},
{"swapwindow", "l", "swap in left direction"},
{"swapwindow", "r", "swap in right direction"},
{"swapwindow", "u", "swap in up direction"},
{"swapwindow", "d", "swap in down direction"},
{"movetoworkspace", "+1", "move to right workspace (non-silent)"},
{"movetoworkspace", "-1", "move to left workspace (non-silent)"},
{"movetoworkspace", "3", "move to workspace 3 (non-silent)"},
{"movetoworkspacesilent", "+1", "move to right workspace"},
{"movetoworkspacesilent", "-1", "move to right workspace"},
{"movetoworkspacesilent", "2", "move to workspace 2"},
{"togglespecialworkspace", "", "toggle special"},
{"exec", "firefox", "firefox"},
{"unknown", "", ""},
}
for _, tt := range tests {
t.Run(tt.dispatcher+"_"+tt.params, func(t *testing.T) {
result := autogenerateComment(tt.dispatcher, tt.params)
if result != tt.expected {
t.Errorf("autogenerateComment(%q, %q) = %q, want %q",
tt.dispatcher, tt.params, result, tt.expected)
}
})
}
}
func TestGetKeybindAtLine(t *testing.T) {
tests := []struct {
name string
line string
expected *KeyBinding
}{
{
name: "basic_keybind",
line: "bind = SUPER, Q, killactive",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "Q",
Dispatcher: "killactive",
Params: "",
Comment: "Close window",
},
},
{
name: "keybind_with_params",
line: "bind = SUPER, left, movefocus, l",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "left",
Dispatcher: "movefocus",
Params: "l",
Comment: "move focus left",
},
},
{
name: "keybind_with_comment",
line: "bind = SUPER, T, exec, kitty # Open terminal",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "T",
Dispatcher: "exec",
Params: "kitty",
Comment: "Open terminal",
},
},
{
name: "keybind_hidden",
line: "bind = SUPER, H, exec, secret # [hidden]",
expected: nil,
},
{
name: "keybind_multiple_mods",
line: "bind = SUPER+SHIFT, F, fullscreen, 0",
expected: &KeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "F",
Dispatcher: "fullscreen",
Params: "0",
Comment: "Toggle fullscreen",
},
},
{
name: "keybind_no_mods",
line: "bind = , Print, exec, screenshot",
expected: &KeyBinding{
Mods: []string{},
Key: "Print",
Dispatcher: "exec",
Params: "screenshot",
Comment: "screenshot",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if tt.expected == nil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Errorf("expected %+v, got nil", tt.expected)
return
}
if result.Key != tt.expected.Key {
t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key)
}
if result.Dispatcher != tt.expected.Dispatcher {
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expected.Dispatcher)
}
if result.Params != tt.expected.Params {
t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params)
}
if result.Comment != tt.expected.Comment {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment)
}
if len(result.Mods) != len(tt.expected.Mods) {
t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods))
} else {
for i := range result.Mods {
if result.Mods[i] != tt.expected.Mods[i] {
t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i])
}
}
}
})
}
}
func TestParseKeysWithSections(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "hyprland.conf")
content := `##! Window Management
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 0
###! Movement
bind = SUPER, left, movefocus, l
bind = SUPER, right, movefocus, r
##! Applications
bind = SUPER, T, exec, kitty # Terminal
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseKeys(tmpDir)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(section.Children) != 2 {
t.Errorf("Expected 2 top-level sections, got %d", len(section.Children))
}
if len(section.Children) >= 1 {
windowMgmt := section.Children[0]
if windowMgmt.Name != "Window Management" {
t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management")
}
if len(windowMgmt.Keybinds) != 2 {
t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds))
}
if len(windowMgmt.Children) != 1 {
t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children))
} else {
movement := windowMgmt.Children[0]
if movement.Name != "Movement" {
t.Errorf("Movement section name = %q, want %q", movement.Name, "Movement")
}
if len(movement.Keybinds) != 2 {
t.Errorf("Movement keybinds = %d, want 2", len(movement.Keybinds))
}
}
}
if len(section.Children) >= 2 {
apps := section.Children[1]
if apps.Name != "Applications" {
t.Errorf("Second section name = %q, want %q", apps.Name, "Applications")
}
if len(apps.Keybinds) != 1 {
t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds))
}
if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" {
t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal")
}
}
}
func TestParseKeysWithCommentBinds(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
content := `#/# = SUPER, A, exec, app1
bind = SUPER, B, exec, app2
#/# = SUPER, C, exec, app3 # Custom comment
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseKeys(tmpDir)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(section.Keybinds) != 3 {
t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds))
}
if len(section.Keybinds) > 0 && section.Keybinds[0].Key != "A" {
t.Errorf("First keybind key = %q, want %q", section.Keybinds[0].Key, "A")
}
if len(section.Keybinds) > 1 && section.Keybinds[1].Key != "B" {
t.Errorf("Second keybind key = %q, want %q", section.Keybinds[1].Key, "B")
}
if len(section.Keybinds) > 2 && section.Keybinds[2].Comment != "Custom comment" {
t.Errorf("Third keybind comment = %q, want %q", section.Keybinds[2].Comment, "Custom comment")
}
}
func TestReadContentMultipleFiles(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "a.conf")
file2 := filepath.Join(tmpDir, "b.conf")
content1 := "bind = SUPER, Q, killactive\n"
content2 := "bind = SUPER, T, exec, kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0644); err != nil {
t.Fatalf("Failed to write file1: %v", err)
}
if err := os.WriteFile(file2, []byte(content2), 0644); err != nil {
t.Fatalf("Failed to write file2: %v", err)
}
parser := NewParser()
if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
section := parser.ParseKeys()
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds from multiple files, got %d", len(section.Keybinds))
}
}
func TestReadContentErrors(t *testing.T) {
tests := []struct {
name string
path string
}{
{
name: "nonexistent_directory",
path: "/nonexistent/path/that/does/not/exist",
},
{
name: "empty_directory",
path: t.TempDir(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseKeys(tt.path)
if err == nil {
t.Error("Expected error, got nil")
}
})
}
}
func TestReadContentWithTildeExpansion(t *testing.T) {
homeDir, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0755); err != nil {
t.Skip("Cannot create test directory in home")
}
defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "test.conf")
if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
relPath, err := filepath.Rel(homeDir, tmpSubdir)
if err != nil {
t.Skip("Cannot create relative path")
}
parser := NewParser()
tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch)
if err != nil {
t.Errorf("ReadContent with tilde path failed: %v", err)
}
}
func TestKeybindWithParamsContainingCommas(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
result := parser.getKeybindAtLine(0)
if result == nil {
t.Fatal("Expected keybind, got nil")
}
expected := "notify-send 'Title' 'Message, with comma'"
if result.Params != expected {
t.Errorf("Params = %q, want %q", result.Params, expected)
}
}
func TestEmptyAndCommentLines(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
content := `
# This is a comment
bind = SUPER, Q, killactive
# Another comment
bind = SUPER, T, exec, kitty
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
section, err := ParseKeys(tmpDir)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
}
}

View File

@@ -0,0 +1,125 @@
package keybinds
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type DiscoveryConfig struct {
SearchPaths []string
}
func DefaultDiscoveryConfig() *DiscoveryConfig {
var searchPaths []string
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
if homeDir, err := os.UserHomeDir(); err == nil {
configHome = filepath.Join(homeDir, ".config")
}
}
if configHome != "" {
searchPaths = append(searchPaths, filepath.Join(configHome, "DankMaterialShell", "cheatsheets"))
}
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs != "" {
for _, dir := range strings.Split(configDirs, ":") {
if dir != "" {
searchPaths = append(searchPaths, filepath.Join(dir, "DankMaterialShell", "cheatsheets"))
}
}
}
return &DiscoveryConfig{
SearchPaths: searchPaths,
}
}
func (d *DiscoveryConfig) FindJSONFiles() ([]string, error) {
var files []string
for _, searchPath := range d.SearchPaths {
expandedPath, err := expandPath(searchPath)
if err != nil {
continue
}
if _, err := os.Stat(expandedPath); os.IsNotExist(err) {
continue
}
entries, err := os.ReadDir(expandedPath)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
fullPath := filepath.Join(expandedPath, entry.Name())
files = append(files, fullPath)
}
}
return files, nil
}
func expandPath(path string) (string, error) {
expandedPath := os.ExpandEnv(path)
if filepath.HasPrefix(expandedPath, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
expandedPath = filepath.Join(home, expandedPath[1:])
}
return filepath.Clean(expandedPath), nil
}
type JSONProviderFactory func(filePath string) (Provider, error)
var jsonProviderFactory JSONProviderFactory
func SetJSONProviderFactory(factory JSONProviderFactory) {
jsonProviderFactory = factory
}
func AutoDiscoverProviders(registry *Registry, config *DiscoveryConfig) error {
if config == nil {
config = DefaultDiscoveryConfig()
}
if jsonProviderFactory == nil {
return nil
}
files, err := config.FindJSONFiles()
if err != nil {
return fmt.Errorf("failed to discover JSON files: %w", err)
}
for _, file := range files {
provider, err := jsonProviderFactory(file)
if err != nil {
continue
}
if err := registry.Register(provider); err != nil {
continue
}
}
return nil
}

View File

@@ -0,0 +1,285 @@
package keybinds
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultDiscoveryConfig(t *testing.T) {
oldConfigHome := os.Getenv("XDG_CONFIG_HOME")
oldConfigDirs := os.Getenv("XDG_CONFIG_DIRS")
defer func() {
os.Setenv("XDG_CONFIG_HOME", oldConfigHome)
os.Setenv("XDG_CONFIG_DIRS", oldConfigDirs)
}()
tests := []struct {
name string
configHome string
configDirs string
expectedCount int
checkFirstPath bool
firstPath string
}{
{
name: "default with no XDG vars",
configHome: "",
configDirs: "",
expectedCount: 1,
checkFirstPath: true,
},
{
name: "with XDG_CONFIG_HOME set",
configHome: "/custom/config",
configDirs: "",
expectedCount: 1,
checkFirstPath: true,
firstPath: "/custom/config/DankMaterialShell/cheatsheets",
},
{
name: "with XDG_CONFIG_DIRS set",
configHome: "/home/user/.config",
configDirs: "/etc/xdg:/opt/config",
expectedCount: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("XDG_CONFIG_HOME", tt.configHome)
os.Setenv("XDG_CONFIG_DIRS", tt.configDirs)
config := DefaultDiscoveryConfig()
if config == nil {
t.Fatal("DefaultDiscoveryConfig returned nil")
}
if len(config.SearchPaths) != tt.expectedCount {
t.Errorf("SearchPaths count = %d, want %d", len(config.SearchPaths), tt.expectedCount)
}
if tt.checkFirstPath && len(config.SearchPaths) > 0 {
if tt.firstPath != "" && config.SearchPaths[0] != tt.firstPath {
t.Errorf("SearchPaths[0] = %q, want %q", config.SearchPaths[0], tt.firstPath)
}
}
})
}
}
func TestFindJSONFiles(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "tmux.json")
file2 := filepath.Join(tmpDir, "vim.json")
txtFile := filepath.Join(tmpDir, "readme.txt")
subdir := filepath.Join(tmpDir, "subdir")
if err := os.WriteFile(file1, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file1: %v", err)
}
if err := os.WriteFile(file2, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file2: %v", err)
}
if err := os.WriteFile(txtFile, []byte("text"), 0644); err != nil {
t.Fatalf("Failed to create txt file: %v", err)
}
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir},
}
files, err := config.FindJSONFiles()
if err != nil {
t.Fatalf("FindJSONFiles failed: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 JSON files, got %d", len(files))
}
found := make(map[string]bool)
for _, f := range files {
found[filepath.Base(f)] = true
}
if !found["tmux.json"] {
t.Error("tmux.json not found")
}
if !found["vim.json"] {
t.Error("vim.json not found")
}
if found["readme.txt"] {
t.Error("readme.txt should not be included")
}
}
func TestFindJSONFilesNonexistentPath(t *testing.T) {
config := &DiscoveryConfig{
SearchPaths: []string{"/nonexistent/path"},
}
files, err := config.FindJSONFiles()
if err != nil {
t.Fatalf("FindJSONFiles failed: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 files for nonexistent path, got %d", len(files))
}
}
func TestFindJSONFilesMultiplePaths(t *testing.T) {
tmpDir1 := t.TempDir()
tmpDir2 := t.TempDir()
file1 := filepath.Join(tmpDir1, "app1.json")
file2 := filepath.Join(tmpDir2, "app2.json")
if err := os.WriteFile(file1, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file1: %v", err)
}
if err := os.WriteFile(file2, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file2: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir1, tmpDir2},
}
files, err := config.FindJSONFiles()
if err != nil {
t.Fatalf("FindJSONFiles failed: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 JSON files from multiple paths, got %d", len(files))
}
}
func TestAutoDiscoverProviders(t *testing.T) {
tmpDir := t.TempDir()
jsonContent := `{
"title": "Test App",
"provider": "testapp",
"binds": {}
}`
file := filepath.Join(tmpDir, "testapp.json")
if err := os.WriteFile(file, []byte(jsonContent), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir},
}
registry := NewRegistry()
factoryCalled := false
SetJSONProviderFactory(func(filePath string) (Provider, error) {
factoryCalled = true
return &mockProvider{name: "testapp"}, nil
})
err := AutoDiscoverProviders(registry, config)
if err != nil {
t.Fatalf("AutoDiscoverProviders failed: %v", err)
}
if !factoryCalled {
t.Error("factory was not called")
}
provider, err := registry.Get("testapp")
if err != nil {
t.Fatalf("provider not registered: %v", err)
}
if provider.Name() != "testapp" {
t.Errorf("provider name = %q, want %q", provider.Name(), "testapp")
}
}
func TestAutoDiscoverProvidersNilConfig(t *testing.T) {
registry := NewRegistry()
SetJSONProviderFactory(func(filePath string) (Provider, error) {
return &mockProvider{name: "test"}, nil
})
err := AutoDiscoverProviders(registry, nil)
if err != nil {
t.Fatalf("AutoDiscoverProviders with nil config failed: %v", err)
}
}
func TestAutoDiscoverProvidersNoFactory(t *testing.T) {
tmpDir := t.TempDir()
file := filepath.Join(tmpDir, "test.json")
if err := os.WriteFile(file, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
config := &DiscoveryConfig{
SearchPaths: []string{tmpDir},
}
registry := NewRegistry()
SetJSONProviderFactory(nil)
err := AutoDiscoverProviders(registry, config)
if err != nil {
t.Fatalf("AutoDiscoverProviders should not fail without factory: %v", err)
}
providers := registry.List()
if len(providers) != 0 {
t.Errorf("expected 0 providers without factory, got %d", len(providers))
}
}
func TestExpandPathInDiscovery(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tests := []struct {
name string
input string
expected string
}{
{
name: "tilde expansion",
input: "~/test",
expected: filepath.Join(home, "test"),
},
{
name: "absolute path",
input: "/tmp/test",
expected: "/tmp/test",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandPath(tt.input)
if err != nil {
t.Fatalf("expandPath failed: %v", err)
}
if result != tt.expected {
t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,116 @@
package providers
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/hyprland"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/keybinds"
)
type HyprlandProvider struct {
configPath string
}
func NewHyprlandProvider(configPath string) *HyprlandProvider {
if configPath == "" {
configPath = "$HOME/.config/hypr"
}
return &HyprlandProvider{
configPath: configPath,
}
}
func (h *HyprlandProvider) Name() string {
return "hyprland"
}
func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
section, err := hyprland.ParseKeys(h.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse hyprland config: %w", err)
}
categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(section, "", categorizedBinds)
return &keybinds.CheatSheet{
Title: "Hyprland Keybinds",
Provider: h.Name(),
Binds: categorizedBinds,
}, nil
}
func (h *HyprlandProvider) convertSection(section *hyprland.Section, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
currentSubcat := subcategory
if section.Name != "" {
currentSubcat = section.Name
}
for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds)
}
}
func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
switch {
case strings.Contains(dispatcher, "workspace"):
return "Workspace"
case strings.Contains(dispatcher, "monitor"):
return "Monitor"
case strings.Contains(dispatcher, "window") ||
strings.Contains(dispatcher, "focus") ||
strings.Contains(dispatcher, "move") ||
strings.Contains(dispatcher, "swap") ||
strings.Contains(dispatcher, "resize") ||
dispatcher == "killactive" ||
dispatcher == "fullscreen" ||
dispatcher == "togglefloating" ||
dispatcher == "pin" ||
dispatcher == "fakefullscreen" ||
dispatcher == "splitratio" ||
dispatcher == "resizeactive":
return "Window"
case dispatcher == "exec":
return "Execute"
case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"):
return "System"
default:
return "Other"
}
}
func (h *HyprlandProvider) convertKeybind(kb *hyprland.KeyBinding, subcategory string) keybinds.Keybind {
key := h.formatKey(kb)
desc := kb.Comment
if desc == "" {
desc = h.generateDescription(kb.Dispatcher, kb.Params)
}
return keybinds.Keybind{
Key: key,
Description: desc,
Subcategory: subcategory,
}
}
func (h *HyprlandProvider) generateDescription(dispatcher, params string) string {
if params != "" {
return dispatcher + " " + params
}
return dispatcher
}
func (h *HyprlandProvider) formatKey(kb *hyprland.KeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}

View File

@@ -0,0 +1,225 @@
package providers
import (
"os"
"path/filepath"
"testing"
)
func TestNewHyprlandProvider(t *testing.T) {
tests := []struct {
name string
configPath string
wantPath string
}{
{
name: "custom path",
configPath: "/custom/path",
wantPath: "/custom/path",
},
{
name: "empty path defaults",
configPath: "",
wantPath: "$HOME/.config/hypr",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewHyprlandProvider(tt.configPath)
if p == nil {
t.Fatal("NewHyprlandProvider returned nil")
}
if p.configPath != tt.wantPath {
t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath)
}
})
}
}
func TestHyprlandProviderName(t *testing.T) {
p := NewHyprlandProvider("")
if p.Name() != "hyprland" {
t.Errorf("Name() = %q, want %q", p.Name(), "hyprland")
}
}
func TestHyprlandProviderGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "hyprland.conf")
content := `##! Window Management
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 0
###! Movement
bind = SUPER, left, movefocus, l
bind = SUPER, right, movefocus, r
##! Applications
bind = SUPER, T, exec, kitty # Terminal
bind = SUPER, 1, workspace, 1
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
p := NewHyprlandProvider(tmpDir)
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet.Title != "Hyprland Keybinds" {
t.Errorf("Title = %q, want %q", sheet.Title, "Hyprland Keybinds")
}
if sheet.Provider != "hyprland" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "hyprland")
}
if len(sheet.Binds) == 0 {
t.Error("expected categorized bindings, got none")
}
if windowBinds, ok := sheet.Binds["Window"]; !ok || len(windowBinds) == 0 {
t.Error("expected Window category with bindings")
}
if execBinds, ok := sheet.Binds["Execute"]; !ok || len(execBinds) == 0 {
t.Error("expected Execute category with bindings")
}
if wsBinds, ok := sheet.Binds["Workspace"]; !ok || len(wsBinds) == 0 {
t.Error("expected Workspace category with bindings")
}
}
func TestHyprlandProviderGetCheatSheetError(t *testing.T) {
p := NewHyprlandProvider("/nonexistent/path")
_, err := p.GetCheatSheet()
if err == nil {
t.Error("expected error for nonexistent path, got nil")
}
}
func TestFormatKey(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
tests := []struct {
name string
content string
expected string
category string
}{
{
name: "single mod",
content: "bind = SUPER, Q, killactive",
expected: "SUPER+Q",
category: "Window",
},
{
name: "multiple mods",
content: "bind = SUPER+SHIFT, F, fullscreen, 0",
expected: "SUPER+SHIFT+F",
category: "Window",
},
{
name: "no mods",
content: "bind = , Print, exec, screenshot",
expected: "Print",
category: "Execute",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
p := NewHyprlandProvider(tmpDir)
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
categoryBinds, ok := sheet.Binds[tt.category]
if !ok || len(categoryBinds) == 0 {
t.Fatalf("expected binds in category %q", tt.category)
}
if categoryBinds[0].Key != tt.expected {
t.Errorf("Key = %q, want %q", categoryBinds[0].Key, tt.expected)
}
})
}
}
func TestDescriptionFallback(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
tests := []struct {
name string
content string
wantDesc string
}{
{
name: "autogenerated description for known dispatcher",
content: "bind = SUPER, Q, killactive",
wantDesc: "Close window",
},
{
name: "custom comment overrides autogeneration",
content: "bind = SUPER, T, exec, kitty # Open terminal",
wantDesc: "Open terminal",
},
{
name: "fallback for unknown dispatcher without params",
content: "bind = SUPER, W, unknowndispatcher",
wantDesc: "unknowndispatcher",
},
{
name: "fallback for unknown dispatcher with params",
content: "bind = SUPER, X, customdispatcher, arg1",
wantDesc: "customdispatcher arg1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
p := NewHyprlandProvider(tmpDir)
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
found := false
for _, binds := range sheet.Binds {
for _, bind := range binds {
if bind.Description == tt.wantDesc {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("expected description %q not found in any bind", tt.wantDesc)
}
})
}
}

View File

@@ -0,0 +1,130 @@
package providers
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/keybinds"
)
type JSONFileProvider struct {
filePath string
name string
}
func NewJSONFileProvider(filePath string) (*JSONFileProvider, error) {
if filePath == "" {
return nil, fmt.Errorf("file path cannot be empty")
}
expandedPath, err := expandPath(filePath)
if err != nil {
return nil, fmt.Errorf("failed to expand path: %w", err)
}
name := filepath.Base(expandedPath)
name = name[:len(name)-len(filepath.Ext(name))]
return &JSONFileProvider{
filePath: expandedPath,
name: name,
}, nil
}
func (j *JSONFileProvider) Name() string {
return j.name
}
func (j *JSONFileProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
data, err := os.ReadFile(j.filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var rawData map[string]interface{}
if err := json.Unmarshal(data, &rawData); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
title, _ := rawData["title"].(string)
provider, _ := rawData["provider"].(string)
if provider == "" {
provider = j.name
}
categorizedBinds := make(map[string][]keybinds.Keybind)
bindsRaw, ok := rawData["binds"]
if !ok {
return nil, fmt.Errorf("missing 'binds' field")
}
switch binds := bindsRaw.(type) {
case map[string]interface{}:
for category, categoryBindsRaw := range binds {
categoryBindsList, ok := categoryBindsRaw.([]interface{})
if !ok {
continue
}
var keybindsList []keybinds.Keybind
categoryBindsJSON, _ := json.Marshal(categoryBindsList)
if err := json.Unmarshal(categoryBindsJSON, &keybindsList); err != nil {
continue
}
categorizedBinds[category] = keybindsList
}
case []interface{}:
flatBindsJSON, _ := json.Marshal(binds)
var flatBinds []struct {
Key string `json:"key"`
Description string `json:"desc"`
Category string `json:"cat,omitempty"`
Subcategory string `json:"subcat,omitempty"`
}
if err := json.Unmarshal(flatBindsJSON, &flatBinds); err != nil {
return nil, fmt.Errorf("failed to parse flat binds array: %w", err)
}
for _, bind := range flatBinds {
category := bind.Category
if category == "" {
category = "Other"
}
kb := keybinds.Keybind{
Key: bind.Key,
Description: bind.Description,
Subcategory: bind.Subcategory,
}
categorizedBinds[category] = append(categorizedBinds[category], kb)
}
default:
return nil, fmt.Errorf("'binds' must be either an object (categorized) or array (flat)")
}
return &keybinds.CheatSheet{
Title: title,
Provider: provider,
Binds: categorizedBinds,
}, nil
}
func expandPath(path string) (string, error) {
expandedPath := os.ExpandEnv(path)
if filepath.HasPrefix(expandedPath, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
expandedPath = filepath.Join(home, expandedPath[1:])
}
return filepath.Clean(expandedPath), nil
}

View File

@@ -0,0 +1,279 @@
package providers
import (
"os"
"path/filepath"
"testing"
)
func TestNewJSONFileProvider(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.json")
if err := os.WriteFile(testFile, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
tests := []struct {
name string
filePath string
expectError bool
wantName string
}{
{
name: "valid file",
filePath: testFile,
expectError: false,
wantName: "test",
},
{
name: "empty path",
filePath: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, err := NewJSONFileProvider(tt.filePath)
if tt.expectError {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if p.Name() != tt.wantName {
t.Errorf("Name() = %q, want %q", p.Name(), tt.wantName)
}
})
}
}
func TestJSONFileProviderGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "tmux.json")
content := `{
"title": "Tmux Binds",
"provider": "tmux",
"binds": {
"Pane": [
{
"key": "Ctrl+Alt+J",
"desc": "Resize split downward",
"subcat": "Sizing"
},
{
"key": "Ctrl+K",
"desc": "Move Focus Up",
"subcat": "Navigation"
}
]
}
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet.Title != "Tmux Binds" {
t.Errorf("Title = %q, want %q", sheet.Title, "Tmux Binds")
}
if sheet.Provider != "tmux" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "tmux")
}
paneBinds, ok := sheet.Binds["Pane"]
if !ok {
t.Fatal("expected Pane category")
}
if len(paneBinds) != 2 {
t.Errorf("len(Pane binds) = %d, want 2", len(paneBinds))
}
if len(paneBinds) > 0 {
bind := paneBinds[0]
if bind.Key != "Ctrl+Alt+J" {
t.Errorf("Pane[0].Key = %q, want %q", bind.Key, "Ctrl+Alt+J")
}
if bind.Description != "Resize split downward" {
t.Errorf("Pane[0].Description = %q, want %q", bind.Description, "Resize split downward")
}
if bind.Subcategory != "Sizing" {
t.Errorf("Pane[0].Subcategory = %q, want %q", bind.Subcategory, "Sizing")
}
}
}
func TestJSONFileProviderGetCheatSheetNoProvider(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "custom.json")
content := `{
"title": "Custom Binds",
"binds": {}
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet.Provider != "custom" {
t.Errorf("Provider = %q, want %q (should default to filename)", sheet.Provider, "custom")
}
}
func TestJSONFileProviderFlatArrayBackwardsCompat(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "legacy.json")
content := `{
"title": "Legacy Format",
"provider": "legacy",
"binds": [
{
"key": "Ctrl+S",
"desc": "Save file",
"cat": "File",
"subcat": "Operations"
},
{
"key": "Ctrl+O",
"desc": "Open file",
"cat": "File"
},
{
"key": "Ctrl+Q",
"desc": "Quit",
"subcat": "Exit"
}
]
}`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
sheet, err := p.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
fileBinds, ok := sheet.Binds["File"]
if !ok || len(fileBinds) != 2 {
t.Errorf("expected 2 binds in File category, got %d", len(fileBinds))
}
otherBinds, ok := sheet.Binds["Other"]
if !ok || len(otherBinds) != 1 {
t.Errorf("expected 1 bind in Other category (no cat specified), got %d", len(otherBinds))
}
if len(fileBinds) > 0 {
if fileBinds[0].Subcategory != "Operations" {
t.Errorf("expected subcategory %q, got %q", "Operations", fileBinds[0].Subcategory)
}
}
}
func TestJSONFileProviderInvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(testFile, []byte("not valid json"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
p, err := NewJSONFileProvider(testFile)
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
_, err = p.GetCheatSheet()
if err == nil {
t.Error("expected error for invalid JSON, got nil")
}
}
func TestJSONFileProviderNonexistentFile(t *testing.T) {
p, err := NewJSONFileProvider("/nonexistent/file.json")
if err != nil {
t.Fatalf("NewJSONFileProvider failed: %v", err)
}
_, err = p.GetCheatSheet()
if err == nil {
t.Error("expected error for nonexistent file, got nil")
}
}
func TestExpandPath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tests := []struct {
name string
input string
expected string
}{
{
name: "tilde expansion",
input: "~/test",
expected: filepath.Join(home, "test"),
},
{
name: "no expansion needed",
input: "/absolute/path",
expected: "/absolute/path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandPath(tt.input)
if err != nil {
t.Fatalf("expandPath failed: %v", err)
}
if result != tt.expected {
t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,112 @@
package providers
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/mangowc"
)
type MangoWCProvider struct {
configPath string
}
func NewMangoWCProvider(configPath string) *MangoWCProvider {
if configPath == "" {
configPath = "$HOME/.config/mango"
}
return &MangoWCProvider{
configPath: configPath,
}
}
func (m *MangoWCProvider) Name() string {
return "mangowc"
}
func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
keybinds_list, err := mangowc.ParseKeys(m.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse mangowc config: %w", err)
}
categorizedBinds := make(map[string][]keybinds.Keybind)
for _, kb := range keybinds_list {
category := m.categorizeByCommand(kb.Command)
bind := m.convertKeybind(&kb)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
return &keybinds.CheatSheet{
Title: "MangoWC Keybinds",
Provider: m.Name(),
Binds: categorizedBinds,
}, nil
}
func (m *MangoWCProvider) categorizeByCommand(command string) string {
switch {
case strings.Contains(command, "mon"):
return "Monitor"
case command == "toggleoverview":
return "Overview"
case command == "toggle_scratchpad":
return "Scratchpad"
case strings.Contains(command, "layout") || strings.Contains(command, "proportion"):
return "Layout"
case strings.Contains(command, "gaps"):
return "Gaps"
case strings.Contains(command, "view") || strings.Contains(command, "tag"):
return "Tags"
case command == "focusstack" ||
command == "focusdir" ||
command == "exchange_client" ||
command == "killclient" ||
command == "togglefloating" ||
command == "togglefullscreen" ||
command == "togglefakefullscreen" ||
command == "togglemaximizescreen" ||
command == "toggleglobal" ||
command == "toggleoverlay" ||
command == "minimized" ||
command == "restore_minimized" ||
command == "movewin" ||
command == "resizewin":
return "Window"
case command == "spawn" || command == "spawn_shell":
return "Execute"
case command == "quit" || command == "reload_config":
return "System"
default:
return "Other"
}
}
func (m *MangoWCProvider) convertKeybind(kb *mangowc.KeyBinding) keybinds.Keybind {
key := m.formatKey(kb)
desc := kb.Comment
if desc == "" {
desc = m.generateDescription(kb.Command, kb.Params)
}
return keybinds.Keybind{
Key: key,
Description: desc,
}
}
func (m *MangoWCProvider) generateDescription(command, params string) string {
if params != "" {
return command + " " + params
}
return command
}
func (m *MangoWCProvider) formatKey(kb *mangowc.KeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}

View File

@@ -0,0 +1,313 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/mangowc"
)
func TestMangoWCProviderName(t *testing.T) {
provider := NewMangoWCProvider("")
if provider.Name() != "mangowc" {
t.Errorf("Name() = %q, want %q", provider.Name(), "mangowc")
}
}
func TestMangoWCProviderDefaultPath(t *testing.T) {
provider := NewMangoWCProvider("")
if provider.configPath != "$HOME/.config/mango" {
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango")
}
}
func TestMangoWCProviderCustomPath(t *testing.T) {
customPath := "/custom/path"
provider := NewMangoWCProvider(customPath)
if provider.configPath != customPath {
t.Errorf("configPath = %q, want %q", provider.configPath, customPath)
}
}
func TestMangoWCCategorizeByCommand(t *testing.T) {
tests := []struct {
command string
expected string
}{
{"view", "Tags"},
{"tag", "Tags"},
{"toggleview", "Tags"},
{"viewtoleft", "Tags"},
{"viewtoright", "Tags"},
{"viewtoleft_have_client", "Tags"},
{"tagtoleft", "Tags"},
{"tagtoright", "Tags"},
{"focusmon", "Monitor"},
{"tagmon", "Monitor"},
{"focusstack", "Window"},
{"focusdir", "Window"},
{"exchange_client", "Window"},
{"killclient", "Window"},
{"togglefloating", "Window"},
{"togglefullscreen", "Window"},
{"togglefakefullscreen", "Window"},
{"togglemaximizescreen", "Window"},
{"toggleglobal", "Window"},
{"toggleoverlay", "Window"},
{"minimized", "Window"},
{"restore_minimized", "Window"},
{"movewin", "Window"},
{"resizewin", "Window"},
{"toggleoverview", "Overview"},
{"toggle_scratchpad", "Scratchpad"},
{"setlayout", "Layout"},
{"switch_layout", "Layout"},
{"set_proportion", "Layout"},
{"switch_proportion_preset", "Layout"},
{"incgaps", "Gaps"},
{"togglegaps", "Gaps"},
{"spawn", "Execute"},
{"spawn_shell", "Execute"},
{"quit", "System"},
{"reload_config", "System"},
{"unknown_command", "Other"},
}
provider := NewMangoWCProvider("")
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := provider.categorizeByCommand(tt.command)
if result != tt.expected {
t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected)
}
})
}
}
func TestMangoWCFormatKey(t *testing.T) {
tests := []struct {
name string
keybind *mangowc.KeyBinding
expected string
}{
{
name: "single_mod",
keybind: &mangowc.KeyBinding{
Mods: []string{"ALT"},
Key: "q",
},
expected: "ALT+q",
},
{
name: "multiple_mods",
keybind: &mangowc.KeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "Up",
},
expected: "SUPER+SHIFT+Up",
},
{
name: "no_mods",
keybind: &mangowc.KeyBinding{
Mods: []string{},
Key: "Print",
},
expected: "Print",
},
}
provider := NewMangoWCProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.formatKey(tt.keybind)
if result != tt.expected {
t.Errorf("formatKey() = %q, want %q", result, tt.expected)
}
})
}
}
func TestMangoWCConvertKeybind(t *testing.T) {
tests := []struct {
name string
keybind *mangowc.KeyBinding
wantKey string
wantDesc string
}{
{
name: "with_comment",
keybind: &mangowc.KeyBinding{
Mods: []string{"ALT"},
Key: "t",
Command: "spawn",
Params: "kitty",
Comment: "Open terminal",
},
wantKey: "ALT+t",
wantDesc: "Open terminal",
},
{
name: "without_comment",
keybind: &mangowc.KeyBinding{
Mods: []string{"SUPER"},
Key: "r",
Command: "reload_config",
Params: "",
Comment: "",
},
wantKey: "SUPER+r",
wantDesc: "reload_config",
},
{
name: "with_params_no_comment",
keybind: &mangowc.KeyBinding{
Mods: []string{"CTRL"},
Key: "1",
Command: "view",
Params: "1,0",
Comment: "",
},
wantKey: "CTRL+1",
wantDesc: "view 1,0",
},
}
provider := NewMangoWCProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertKeybind(tt.keybind)
if result.Key != tt.wantKey {
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
}
if result.Description != tt.wantDesc {
t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc)
}
})
}
}
func TestMangoWCGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `# MangoWC Configuration
blur=0
# Key Bindings
bind=SUPER,r,reload_config
bind=Alt,t,spawn,kitty # Terminal
bind=ALT,q,killclient,
# Window management
bind=ALT,Left,focusdir,left
bind=ALT,Right,focusdir,right
bind=SUPER+SHIFT,Up,exchange_client,up
# Tags
bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0
bind=Alt,1,tag,1,0
# Layout
bind=SUPER,n,switch_layout
# Gaps
bind=ALT+SHIFT,X,incgaps,1
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet == nil {
t.Fatal("Expected non-nil CheatSheet")
}
if sheet.Title != "MangoWC Keybinds" {
t.Errorf("Title = %q, want %q", sheet.Title, "MangoWC Keybinds")
}
if sheet.Provider != "mangowc" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "mangowc")
}
categories := []string{"System", "Execute", "Window", "Tags", "Layout", "Gaps"}
for _, category := range categories {
if _, exists := sheet.Binds[category]; !exists {
t.Errorf("Expected category %q to exist", category)
}
}
if len(sheet.Binds["System"]) < 1 {
t.Error("Expected at least 1 System keybind")
}
if len(sheet.Binds["Execute"]) < 1 {
t.Error("Expected at least 1 Execute keybind")
}
if len(sheet.Binds["Window"]) < 3 {
t.Error("Expected at least 3 Window keybinds")
}
if len(sheet.Binds["Tags"]) < 3 {
t.Error("Expected at least 3 Tags keybinds")
}
}
func TestMangoWCGetCheatSheetError(t *testing.T) {
provider := NewMangoWCProvider("/nonexistent/path")
_, err := provider.GetCheatSheet()
if err == nil {
t.Error("Expected error for nonexistent path, got nil")
}
}
func TestMangoWCIntegration(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `bind=Alt,t,spawn,kitty # Open terminal
bind=ALT,q,killclient,
bind=SUPER,r,reload_config # Reload config
bind=ALT,Left,focusdir,left
bind=Ctrl,1,view,1,0
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
totalBinds := 0
for _, binds := range sheet.Binds {
totalBinds += len(binds)
}
expectedBinds := 5
if totalBinds != expectedBinds {
t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds)
}
foundTerminal := false
for _, binds := range sheet.Binds {
for _, bind := range binds {
if bind.Description == "Open terminal" && bind.Key == "Alt+t" {
foundTerminal = true
}
}
}
if !foundTerminal {
t.Error("Did not find terminal keybind with correct key and description")
}
}

View File

@@ -0,0 +1,112 @@
package providers
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/sway"
)
type SwayProvider struct {
configPath string
}
func NewSwayProvider(configPath string) *SwayProvider {
if configPath == "" {
configPath = "$HOME/.config/sway"
}
return &SwayProvider{
configPath: configPath,
}
}
func (s *SwayProvider) Name() string {
return "sway"
}
func (s *SwayProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
section, err := sway.ParseKeys(s.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse sway config: %w", err)
}
categorizedBinds := make(map[string][]keybinds.Keybind)
s.convertSection(section, "", categorizedBinds)
return &keybinds.CheatSheet{
Title: "Sway Keybinds",
Provider: s.Name(),
Binds: categorizedBinds,
}, nil
}
func (s *SwayProvider) convertSection(section *sway.Section, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
currentSubcat := subcategory
if section.Name != "" {
currentSubcat = section.Name
}
for _, kb := range section.Keybinds {
category := s.categorizeByCommand(kb.Command)
bind := s.convertKeybind(&kb, currentSubcat)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
for _, child := range section.Children {
s.convertSection(&child, currentSubcat, categorizedBinds)
}
}
func (s *SwayProvider) categorizeByCommand(command string) string {
command = strings.ToLower(command)
switch {
case strings.Contains(command, "scratchpad"):
return "Scratchpad"
case strings.Contains(command, "workspace") && strings.Contains(command, "output"):
return "Monitor"
case strings.Contains(command, "workspace"):
return "Workspace"
case strings.Contains(command, "output"):
return "Monitor"
case strings.Contains(command, "layout"):
return "Layout"
case command == "kill" ||
command == "fullscreen" || strings.Contains(command, "fullscreen") ||
command == "floating toggle" || strings.Contains(command, "floating") ||
strings.Contains(command, "focus") ||
strings.Contains(command, "move") ||
strings.Contains(command, "resize") ||
strings.Contains(command, "split"):
return "Window"
case strings.HasPrefix(command, "exec"):
return "Execute"
case command == "exit" || command == "reload":
return "System"
default:
return "Other"
}
}
func (s *SwayProvider) convertKeybind(kb *sway.KeyBinding, subcategory string) keybinds.Keybind {
key := s.formatKey(kb)
desc := kb.Comment
if desc == "" {
desc = kb.Command
}
return keybinds.Keybind{
Key: key,
Description: desc,
Subcategory: subcategory,
}
}
func (s *SwayProvider) formatKey(kb *sway.KeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}

View File

@@ -0,0 +1,290 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/sway"
)
func TestSwayProviderName(t *testing.T) {
provider := NewSwayProvider("")
if provider.Name() != "sway" {
t.Errorf("Name() = %q, want %q", provider.Name(), "sway")
}
}
func TestSwayProviderDefaultPath(t *testing.T) {
provider := NewSwayProvider("")
if provider.configPath != "$HOME/.config/sway" {
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/sway")
}
}
func TestSwayProviderCustomPath(t *testing.T) {
customPath := "/custom/path"
provider := NewSwayProvider(customPath)
if provider.configPath != customPath {
t.Errorf("configPath = %q, want %q", provider.configPath, customPath)
}
}
func TestSwayCategorizeByCommand(t *testing.T) {
tests := []struct {
command string
expected string
}{
{"workspace number 1", "Workspace"},
{"workspace prev", "Workspace"},
{"workspace next", "Workspace"},
{"move container to workspace number 1", "Workspace"},
{"focus output left", "Monitor"},
{"move workspace to output right", "Monitor"},
{"kill", "Window"},
{"fullscreen toggle", "Window"},
{"floating toggle", "Window"},
{"focus left", "Window"},
{"focus right", "Window"},
{"move left", "Window"},
{"move right", "Window"},
{"resize grow width 10px", "Window"},
{"splith", "Window"},
{"splitv", "Window"},
{"layout tabbed", "Layout"},
{"layout stacking", "Layout"},
{"move scratchpad", "Scratchpad"},
{"scratchpad show", "Scratchpad"},
{"exec kitty", "Execute"},
{"exec --no-startup-id firefox", "Execute"},
{"exit", "System"},
{"reload", "System"},
{"unknown command", "Other"},
}
provider := NewSwayProvider("")
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := provider.categorizeByCommand(tt.command)
if result != tt.expected {
t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected)
}
})
}
}
func TestSwayFormatKey(t *testing.T) {
tests := []struct {
name string
keybind *sway.KeyBinding
expected string
}{
{
name: "single_mod",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4"},
Key: "q",
},
expected: "Mod4+q",
},
{
name: "multiple_mods",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4", "Shift"},
Key: "e",
},
expected: "Mod4+Shift+e",
},
{
name: "no_mods",
keybind: &sway.KeyBinding{
Mods: []string{},
Key: "Print",
},
expected: "Print",
},
}
provider := NewSwayProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.formatKey(tt.keybind)
if result != tt.expected {
t.Errorf("formatKey() = %q, want %q", result, tt.expected)
}
})
}
}
func TestSwayConvertKeybind(t *testing.T) {
tests := []struct {
name string
keybind *sway.KeyBinding
wantKey string
wantDesc string
}{
{
name: "with_comment",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4"},
Key: "t",
Command: "exec kitty",
Comment: "Open terminal",
},
wantKey: "Mod4+t",
wantDesc: "Open terminal",
},
{
name: "without_comment",
keybind: &sway.KeyBinding{
Mods: []string{"Mod4"},
Key: "r",
Command: "reload",
Comment: "",
},
wantKey: "Mod4+r",
wantDesc: "reload",
},
}
provider := NewSwayProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertKeybind(tt.keybind, "")
if result.Key != tt.wantKey {
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
}
if result.Description != tt.wantDesc {
t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc)
}
})
}
}
func TestSwayGetCheatSheet(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config")
content := `set $mod Mod4
set $term kitty
# System
bindsym $mod+Shift+c reload
bindsym $mod+Shift+e exit
# Applications
bindsym $mod+t exec $term
bindsym $mod+Space exec rofi
# Window Management
bindsym $mod+q kill
bindsym $mod+f fullscreen toggle
bindsym $mod+Left focus left
bindsym $mod+Right focus right
# Workspace
bindsym $mod+1 workspace number 1
bindsym $mod+2 workspace number 2
bindsym $mod+Shift+1 move container to workspace number 1
# Layout
bindsym $mod+s layout stacking
bindsym $mod+w layout tabbed
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewSwayProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
if sheet == nil {
t.Fatal("Expected non-nil CheatSheet")
}
if sheet.Title != "Sway Keybinds" {
t.Errorf("Title = %q, want %q", sheet.Title, "Sway Keybinds")
}
if sheet.Provider != "sway" {
t.Errorf("Provider = %q, want %q", sheet.Provider, "sway")
}
categories := []string{"System", "Execute", "Window", "Workspace", "Layout"}
for _, category := range categories {
if _, exists := sheet.Binds[category]; !exists {
t.Errorf("Expected category %q to exist", category)
}
}
if len(sheet.Binds["System"]) < 2 {
t.Error("Expected at least 2 System keybinds")
}
if len(sheet.Binds["Execute"]) < 2 {
t.Error("Expected at least 2 Execute keybinds")
}
if len(sheet.Binds["Window"]) < 4 {
t.Error("Expected at least 4 Window keybinds")
}
if len(sheet.Binds["Workspace"]) < 3 {
t.Error("Expected at least 3 Workspace keybinds")
}
}
func TestSwayGetCheatSheetError(t *testing.T) {
provider := NewSwayProvider("/nonexistent/path")
_, err := provider.GetCheatSheet()
if err == nil {
t.Error("Expected error for nonexistent path, got nil")
}
}
func TestSwayIntegration(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config")
content := `set $mod Mod4
bindsym $mod+t exec kitty # Terminal
bindsym $mod+q kill
bindsym $mod+f fullscreen toggle
bindsym $mod+1 workspace number 1
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
provider := NewSwayProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatalf("GetCheatSheet failed: %v", err)
}
totalBinds := 0
for _, binds := range sheet.Binds {
totalBinds += len(binds)
}
expectedBinds := 4
if totalBinds != expectedBinds {
t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds)
}
foundTerminal := false
for _, binds := range sheet.Binds {
for _, bind := range binds {
if bind.Description == "Terminal" && bind.Key == "Mod4+t" {
foundTerminal = true
}
}
}
if !foundTerminal {
t.Error("Did not find terminal keybind with correct key and description")
}
}

View File

@@ -0,0 +1,79 @@
package keybinds
import (
"fmt"
"sync"
)
type Registry struct {
mu sync.RWMutex
providers map[string]Provider
}
func NewRegistry() *Registry {
return &Registry{
providers: make(map[string]Provider),
}
}
func (r *Registry) Register(provider Provider) error {
if provider == nil {
return fmt.Errorf("cannot register nil provider")
}
name := provider.Name()
if name == "" {
return fmt.Errorf("provider name cannot be empty")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.providers[name]; exists {
return fmt.Errorf("provider %q already registered", name)
}
r.providers[name] = provider
return nil
}
func (r *Registry) Get(name string) (Provider, error) {
r.mu.RLock()
defer r.mu.RUnlock()
provider, exists := r.providers[name]
if !exists {
return nil, fmt.Errorf("provider %q not found", name)
}
return provider, nil
}
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.providers))
for name := range r.providers {
names = append(names, name)
}
return names
}
var defaultRegistry = NewRegistry()
func GetDefaultRegistry() *Registry {
return defaultRegistry
}
func Register(provider Provider) error {
return defaultRegistry.Register(provider)
}
func Get(name string) (Provider, error) {
return defaultRegistry.Get(name)
}
func List() []string {
return defaultRegistry.List()
}

View File

@@ -0,0 +1,183 @@
package keybinds
import (
"testing"
)
type mockProvider struct {
name string
err error
}
func (m *mockProvider) Name() string {
return m.name
}
func (m *mockProvider) GetCheatSheet() (*CheatSheet, error) {
if m.err != nil {
return nil, m.err
}
return &CheatSheet{
Title: "Test",
Provider: m.name,
Binds: make(map[string][]Keybind),
}, nil
}
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
if r == nil {
t.Fatal("NewRegistry returned nil")
}
if r.providers == nil {
t.Error("providers map is nil")
}
}
func TestRegisterProvider(t *testing.T) {
tests := []struct {
name string
provider Provider
expectError bool
errorMsg string
}{
{
name: "valid provider",
provider: &mockProvider{name: "test"},
expectError: false,
},
{
name: "nil provider",
provider: nil,
expectError: true,
errorMsg: "cannot register nil provider",
},
{
name: "empty name",
provider: &mockProvider{name: ""},
expectError: true,
errorMsg: "provider name cannot be empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRegistry()
err := r.Register(tt.provider)
if tt.expectError {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestRegisterDuplicate(t *testing.T) {
r := NewRegistry()
p := &mockProvider{name: "test"}
if err := r.Register(p); err != nil {
t.Fatalf("first registration failed: %v", err)
}
err := r.Register(p)
if err == nil {
t.Error("expected error when registering duplicate, got nil")
}
}
func TestGetProvider(t *testing.T) {
r := NewRegistry()
p := &mockProvider{name: "test"}
if err := r.Register(p); err != nil {
t.Fatalf("registration failed: %v", err)
}
retrieved, err := r.Get("test")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if retrieved.Name() != "test" {
t.Errorf("Got provider name %q, want %q", retrieved.Name(), "test")
}
}
func TestGetNonexistent(t *testing.T) {
r := NewRegistry()
_, err := r.Get("nonexistent")
if err == nil {
t.Error("expected error for nonexistent provider, got nil")
}
}
func TestListProviders(t *testing.T) {
r := NewRegistry()
p1 := &mockProvider{name: "test1"}
p2 := &mockProvider{name: "test2"}
p3 := &mockProvider{name: "test3"}
r.Register(p1)
r.Register(p2)
r.Register(p3)
list := r.List()
if len(list) != 3 {
t.Errorf("expected 3 providers, got %d", len(list))
}
found := make(map[string]bool)
for _, name := range list {
found[name] = true
}
expected := []string{"test1", "test2", "test3"}
for _, name := range expected {
if !found[name] {
t.Errorf("expected provider %q not found in list", name)
}
}
}
func TestDefaultRegistry(t *testing.T) {
p := &mockProvider{name: "default-test"}
err := Register(p)
if err != nil {
t.Fatalf("Register failed: %v", err)
}
retrieved, err := Get("default-test")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if retrieved.Name() != "default-test" {
t.Errorf("Got provider name %q, want %q", retrieved.Name(), "default-test")
}
list := List()
found := false
for _, name := range list {
if name == "default-test" {
found = true
break
}
}
if !found {
t.Error("provider not found in default registry list")
}
}

View File

@@ -0,0 +1,18 @@
package keybinds
type Keybind struct {
Key string `json:"key"`
Description string `json:"desc"`
Subcategory string `json:"subcat,omitempty"`
}
type CheatSheet struct {
Title string `json:"title"`
Provider string `json:"provider"`
Binds map[string][]Keybind `json:"binds"`
}
type Provider interface {
Name() string
GetCheatSheet() (*CheatSheet, error)
}

116
backend/internal/log/log.go Normal file
View File

@@ -0,0 +1,116 @@
package log
import (
"os"
"strings"
"sync"
"github.com/charmbracelet/lipgloss"
cblog "github.com/charmbracelet/log"
)
// Logger embeds the Charm Logger and adds Printf/Fatalf
type Logger struct{ *cblog.Logger }
// Printf routes goose/info-style logs through Infof.
func (l *Logger) Printf(format string, v ...interface{}) { l.Infof(format, v...) }
// Fatalf keeps gooses contract of exiting the program.
func (l *Logger) Fatalf(format string, v ...interface{}) { l.Logger.Fatalf(format, v...) }
var (
logger *Logger
initLogger sync.Once
)
func parseLogLevel(level string) cblog.Level {
switch strings.ToLower(level) {
case "debug":
return cblog.DebugLevel
case "info":
return cblog.InfoLevel
case "warn", "warning":
return cblog.WarnLevel
case "error":
return cblog.ErrorLevel
case "fatal":
return cblog.FatalLevel
default:
return cblog.InfoLevel
}
}
func GetQtLoggingRules() string {
level := os.Getenv("DMS_LOG_LEVEL")
if level == "" {
level = "info"
}
var rules []string
switch strings.ToLower(level) {
case "fatal":
rules = []string{"*.debug=false", "*.info=false", "*.warning=false", "*.critical=false"}
case "error":
rules = []string{"*.debug=false", "*.info=false", "*.warning=false"}
case "warn", "warning":
rules = []string{"*.debug=false", "*.info=false"}
case "info":
rules = []string{"*.debug=false"}
case "debug":
return ""
default:
rules = []string{"*.debug=false"}
}
return strings.Join(rules, ";")
}
// GetLogger returns a logger instance
func GetLogger() *Logger {
initLogger.Do(func() {
styles := cblog.DefaultStyles()
// Attempt to match the colors used by qml/quickshell logs
styles.Levels[cblog.FatalLevel] = lipgloss.NewStyle().
SetString(" FATAL").
Foreground(lipgloss.Color("1"))
styles.Levels[cblog.ErrorLevel] = lipgloss.NewStyle().
SetString(" ERROR").
Foreground(lipgloss.Color("9"))
styles.Levels[cblog.WarnLevel] = lipgloss.NewStyle().
SetString(" WARN").
Foreground(lipgloss.Color("3"))
styles.Levels[cblog.InfoLevel] = lipgloss.NewStyle().
SetString(" INFO").
Foreground(lipgloss.Color("2"))
styles.Levels[cblog.DebugLevel] = lipgloss.NewStyle().
SetString(" DEBUG").
Foreground(lipgloss.Color("4"))
base := cblog.New(os.Stderr)
base.SetStyles(styles)
base.SetReportTimestamp(false)
level := cblog.InfoLevel
if envLevel := os.Getenv("DMS_LOG_LEVEL"); envLevel != "" {
level = parseLogLevel(envLevel)
}
base.SetLevel(level)
base.SetPrefix(" go")
logger = &Logger{base}
})
return logger
}
// * Convenience wrappers
func Debug(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Debug(msg, keyvals...) }
func Debugf(format string, v ...interface{}) { GetLogger().Logger.Debugf(format, v...) }
func Info(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Info(msg, keyvals...) }
func Infof(format string, v ...interface{}) { GetLogger().Logger.Infof(format, v...) }
func Warn(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Warn(msg, keyvals...) }
func Warnf(format string, v ...interface{}) { GetLogger().Logger.Warnf(format, v...) }
func Error(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Error(msg, keyvals...) }
func Errorf(format string, v ...interface{}) { GetLogger().Logger.Errorf(format, v...) }
func Fatal(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Fatal(msg, keyvals...) }
func Fatalf(format string, v ...interface{}) { GetLogger().Logger.Fatalf(format, v...) }

View File

@@ -0,0 +1,104 @@
package logger
import (
"bufio"
"fmt"
"os"
"regexp"
"sync"
"time"
)
type FileLogger struct {
file *os.File
writer *bufio.Writer
logPath string
mu sync.Mutex
stopChan chan struct{}
doneChan chan struct{}
passwordRe *regexp.Regexp
}
func NewFileLogger() (*FileLogger, error) {
timestamp := time.Now().Unix()
logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp)
file, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
passwordRe := regexp.MustCompile(`(?i)(password[:\s=]+)[^\s]+`)
logger := &FileLogger{
file: file,
writer: bufio.NewWriter(file),
logPath: logPath,
stopChan: make(chan struct{}),
doneChan: make(chan struct{}),
passwordRe: passwordRe,
}
header := fmt.Sprintf("=== DankInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339))
logger.writeToFile(header)
return logger, nil
}
func (l *FileLogger) GetLogPath() string {
return l.logPath
}
func (l *FileLogger) redactPassword(message string) string {
redacted := l.passwordRe.ReplaceAllString(message, "${1}[REDACTED]")
redacted = regexp.MustCompile(`echo\s+'[^']+'`).ReplaceAllString(redacted, "echo '[REDACTED]'")
return redacted
}
func (l *FileLogger) writeToFile(message string) {
l.mu.Lock()
defer l.mu.Unlock()
redacted := l.redactPassword(message)
timestamp := time.Now().Format("15:04:05.000")
l.writer.WriteString(fmt.Sprintf("[%s] %s\n", timestamp, redacted))
l.writer.Flush()
}
func (l *FileLogger) StartListening(logChan <-chan string) {
go func() {
defer close(l.doneChan)
for {
select {
case msg, ok := <-logChan:
if !ok {
return
}
l.writeToFile(msg)
case <-l.stopChan:
return
}
}
}()
}
func (l *FileLogger) Close() error {
close(l.stopChan)
<-l.doneChan
l.mu.Lock()
defer l.mu.Unlock()
footer := fmt.Sprintf("\n=== DankInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339))
l.writer.WriteString(footer)
l.writer.Flush()
if err := l.file.Sync(); err != nil {
return err
}
return l.file.Close()
}

View File

@@ -0,0 +1,305 @@
package mangowc
import (
"os"
"path/filepath"
"regexp"
"strings"
)
const (
HideComment = "[hidden]"
)
var ModSeparators = []rune{'+', ' '}
type KeyBinding struct {
Mods []string `json:"mods"`
Key string `json:"key"`
Command string `json:"command"`
Params string `json:"params"`
Comment string `json:"comment"`
}
type Parser struct {
contentLines []string
readingLine int
}
func NewParser() *Parser {
return &Parser{
contentLines: []string{},
readingLine: 0,
}
}
func (p *Parser) ReadContent(path string) error {
expandedPath := os.ExpandEnv(path)
expandedPath = filepath.Clean(expandedPath)
if strings.HasPrefix(expandedPath, "~") {
home, err := os.UserHomeDir()
if err != nil {
return err
}
expandedPath = filepath.Join(home, expandedPath[1:])
}
info, err := os.Stat(expandedPath)
if err != nil {
return err
}
var files []string
if info.IsDir() {
confFiles, err := filepath.Glob(filepath.Join(expandedPath, "*.conf"))
if err != nil {
return err
}
if len(confFiles) == 0 {
return os.ErrNotExist
}
files = confFiles
} else {
files = []string{expandedPath}
}
var combinedContent []string
for _, file := range files {
if fileInfo, err := os.Stat(file); err == nil && fileInfo.Mode().IsRegular() {
data, err := os.ReadFile(file)
if err == nil {
combinedContent = append(combinedContent, string(data))
}
}
}
if len(combinedContent) == 0 {
return os.ErrNotExist
}
fullContent := strings.Join(combinedContent, "\n")
p.contentLines = strings.Split(fullContent, "\n")
return nil
}
func autogenerateComment(command, params string) string {
switch command {
case "spawn", "spawn_shell":
return params
case "killclient":
return "Close window"
case "quit":
return "Exit MangoWC"
case "reload_config":
return "Reload configuration"
case "focusstack":
if params == "next" {
return "Focus next window"
}
if params == "prev" {
return "Focus previous window"
}
return "Focus stack " + params
case "focusdir":
dirMap := map[string]string{
"left": "left",
"right": "right",
"up": "up",
"down": "down",
}
if dir, ok := dirMap[params]; ok {
return "Focus " + dir
}
return "Focus " + params
case "exchange_client":
dirMap := map[string]string{
"left": "left",
"right": "right",
"up": "up",
"down": "down",
}
if dir, ok := dirMap[params]; ok {
return "Swap window " + dir
}
return "Swap window " + params
case "togglefloating":
return "Float/unfloat window"
case "togglefullscreen":
return "Toggle fullscreen"
case "togglefakefullscreen":
return "Toggle fake fullscreen"
case "togglemaximizescreen":
return "Toggle maximize"
case "toggleglobal":
return "Toggle global"
case "toggleoverview":
return "Toggle overview"
case "toggleoverlay":
return "Toggle overlay"
case "minimized":
return "Minimize window"
case "restore_minimized":
return "Restore minimized"
case "toggle_scratchpad":
return "Toggle scratchpad"
case "setlayout":
return "Set layout " + params
case "switch_layout":
return "Switch layout"
case "view":
parts := strings.Split(params, ",")
if len(parts) > 0 {
return "View tag " + parts[0]
}
return "View tag"
case "tag":
parts := strings.Split(params, ",")
if len(parts) > 0 {
return "Move to tag " + parts[0]
}
return "Move to tag"
case "toggleview":
parts := strings.Split(params, ",")
if len(parts) > 0 {
return "Toggle tag " + parts[0]
}
return "Toggle tag"
case "viewtoleft", "viewtoleft_have_client":
return "View left tag"
case "viewtoright", "viewtoright_have_client":
return "View right tag"
case "tagtoleft":
return "Move to left tag"
case "tagtoright":
return "Move to right tag"
case "focusmon":
return "Focus monitor " + params
case "tagmon":
return "Move to monitor " + params
case "incgaps":
if strings.HasPrefix(params, "-") {
return "Decrease gaps"
}
return "Increase gaps"
case "togglegaps":
return "Toggle gaps"
case "movewin":
return "Move window by " + params
case "resizewin":
return "Resize window by " + params
case "set_proportion":
return "Set proportion " + params
case "switch_proportion_preset":
return "Switch proportion preset"
default:
return ""
}
}
func (p *Parser) getKeybindAtLine(lineNumber int) *KeyBinding {
if lineNumber >= len(p.contentLines) {
return nil
}
line := p.contentLines[lineNumber]
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
bindType := matches[1]
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, HideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = autogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(ModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
_ = bindType
return &KeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
}
func (p *Parser) ParseKeys() []KeyBinding {
var keybinds []KeyBinding
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
line := p.contentLines[lineNumber]
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
if !strings.HasPrefix(strings.TrimSpace(line), "bind") {
continue
}
keybind := p.getKeybindAtLine(lineNumber)
if keybind != nil {
keybinds = append(keybinds, *keybind)
}
}
return keybinds
}
func ParseKeys(path string) ([]KeyBinding, error) {
parser := NewParser()
if err := parser.ReadContent(path); err != nil {
return nil, err
}
return parser.ParseKeys(), nil
}

View File

@@ -0,0 +1,499 @@
package mangowc
import (
"os"
"path/filepath"
"testing"
)
func TestAutogenerateComment(t *testing.T) {
tests := []struct {
command string
params string
expected string
}{
{"spawn", "kitty", "kitty"},
{"spawn_shell", "firefox", "firefox"},
{"killclient", "", "Close window"},
{"quit", "", "Exit MangoWC"},
{"reload_config", "", "Reload configuration"},
{"focusstack", "next", "Focus next window"},
{"focusstack", "prev", "Focus previous window"},
{"focusdir", "left", "Focus left"},
{"focusdir", "right", "Focus right"},
{"focusdir", "up", "Focus up"},
{"focusdir", "down", "Focus down"},
{"exchange_client", "left", "Swap window left"},
{"exchange_client", "right", "Swap window right"},
{"togglefloating", "", "Float/unfloat window"},
{"togglefullscreen", "", "Toggle fullscreen"},
{"togglefakefullscreen", "", "Toggle fake fullscreen"},
{"togglemaximizescreen", "", "Toggle maximize"},
{"toggleglobal", "", "Toggle global"},
{"toggleoverview", "", "Toggle overview"},
{"toggleoverlay", "", "Toggle overlay"},
{"minimized", "", "Minimize window"},
{"restore_minimized", "", "Restore minimized"},
{"toggle_scratchpad", "", "Toggle scratchpad"},
{"setlayout", "tile", "Set layout tile"},
{"switch_layout", "", "Switch layout"},
{"view", "1,0", "View tag 1"},
{"tag", "2,0", "Move to tag 2"},
{"toggleview", "3,0", "Toggle tag 3"},
{"viewtoleft", "", "View left tag"},
{"viewtoright", "", "View right tag"},
{"viewtoleft_have_client", "", "View left tag"},
{"viewtoright_have_client", "", "View right tag"},
{"tagtoleft", "", "Move to left tag"},
{"tagtoright", "", "Move to right tag"},
{"focusmon", "left", "Focus monitor left"},
{"tagmon", "right", "Move to monitor right"},
{"incgaps", "1", "Increase gaps"},
{"incgaps", "-1", "Decrease gaps"},
{"togglegaps", "", "Toggle gaps"},
{"movewin", "+0,-50", "Move window by +0,-50"},
{"resizewin", "+0,+50", "Resize window by +0,+50"},
{"set_proportion", "1.0", "Set proportion 1.0"},
{"switch_proportion_preset", "", "Switch proportion preset"},
{"unknown", "", ""},
}
for _, tt := range tests {
t.Run(tt.command+"_"+tt.params, func(t *testing.T) {
result := autogenerateComment(tt.command, tt.params)
if result != tt.expected {
t.Errorf("autogenerateComment(%q, %q) = %q, want %q",
tt.command, tt.params, result, tt.expected)
}
})
}
}
func TestGetKeybindAtLine(t *testing.T) {
tests := []struct {
name string
line string
expected *KeyBinding
}{
{
name: "basic_keybind",
line: "bind=ALT,q,killclient,",
expected: &KeyBinding{
Mods: []string{"ALT"},
Key: "q",
Command: "killclient",
Params: "",
Comment: "Close window",
},
},
{
name: "keybind_with_params",
line: "bind=ALT,Left,focusdir,left",
expected: &KeyBinding{
Mods: []string{"ALT"},
Key: "Left",
Command: "focusdir",
Params: "left",
Comment: "Focus left",
},
},
{
name: "keybind_with_comment",
line: "bind=Alt,t,spawn,kitty # Open terminal",
expected: &KeyBinding{
Mods: []string{"Alt"},
Key: "t",
Command: "spawn",
Params: "kitty",
Comment: "Open terminal",
},
},
{
name: "keybind_hidden",
line: "bind=SUPER,h,spawn,secret # [hidden]",
expected: nil,
},
{
name: "keybind_multiple_mods",
line: "bind=SUPER+SHIFT,Up,exchange_client,up",
expected: &KeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "Up",
Command: "exchange_client",
Params: "up",
Comment: "Swap window up",
},
},
{
name: "keybind_no_mods",
line: "bind=NONE,Print,spawn,screenshot",
expected: &KeyBinding{
Mods: []string{},
Key: "Print",
Command: "spawn",
Params: "screenshot",
Comment: "screenshot",
},
},
{
name: "keybind_multiple_params",
line: "bind=Ctrl,1,view,1,0",
expected: &KeyBinding{
Mods: []string{"Ctrl"},
Key: "1",
Command: "view",
Params: "1,0",
Comment: "View tag 1",
},
},
{
name: "bindl_flag",
line: "bindl=SUPER+ALT,l,spawn,dms ipc call lock lock",
expected: &KeyBinding{
Mods: []string{"SUPER", "ALT"},
Key: "l",
Command: "spawn",
Params: "dms ipc call lock lock",
Comment: "dms ipc call lock lock",
},
},
{
name: "keybind_with_spaces",
line: "bind = SUPER, r, reload_config",
expected: &KeyBinding{
Mods: []string{"SUPER"},
Key: "r",
Command: "reload_config",
Params: "",
Comment: "Reload configuration",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if tt.expected == nil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Errorf("expected %+v, got nil", tt.expected)
return
}
if result.Key != tt.expected.Key {
t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key)
}
if result.Command != tt.expected.Command {
t.Errorf("Command = %q, want %q", result.Command, tt.expected.Command)
}
if result.Params != tt.expected.Params {
t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params)
}
if result.Comment != tt.expected.Comment {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment)
}
if len(result.Mods) != len(tt.expected.Mods) {
t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods))
} else {
for i := range result.Mods {
if result.Mods[i] != tt.expected.Mods[i] {
t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i])
}
}
}
})
}
}
func TestParseKeys(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `# MangoWC Configuration
blur=0
border_radius=12
# Key Bindings
bind=SUPER,r,reload_config
bind=Alt,t,spawn,kitty # Terminal
bind=ALT,q,killclient,
bind=ALT,Left,focusdir,left
# Hidden binding
bind=SUPER,h,spawn,secret # [hidden]
# Multiple modifiers
bind=SUPER+SHIFT,Up,exchange_client,up
# Workspace bindings
bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
keybinds, err := ParseKeys(configFile)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
expectedCount := 7
if len(keybinds) != expectedCount {
t.Errorf("Expected %d keybinds, got %d", expectedCount, len(keybinds))
}
if len(keybinds) > 0 && keybinds[0].Command != "reload_config" {
t.Errorf("First keybind command = %q, want %q", keybinds[0].Command, "reload_config")
}
foundHidden := false
for _, kb := range keybinds {
if kb.Command == "spawn" && kb.Params == "secret" {
foundHidden = true
}
}
if foundHidden {
t.Error("Hidden keybind should not be included in results")
}
}
func TestReadContentMultipleFiles(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "a.conf")
file2 := filepath.Join(tmpDir, "b.conf")
content1 := "bind=ALT,q,killclient,\n"
content2 := "bind=Alt,t,spawn,kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0644); err != nil {
t.Fatalf("Failed to write file1: %v", err)
}
if err := os.WriteFile(file2, []byte(content2), 0644); err != nil {
t.Fatalf("Failed to write file2: %v", err)
}
parser := NewParser()
if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
keybinds := parser.ParseKeys()
if len(keybinds) != 2 {
t.Errorf("Expected 2 keybinds from multiple files, got %d", len(keybinds))
}
}
func TestReadContentSingleFile(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := "bind=ALT,q,killclient,\n"
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write config: %v", err)
}
parser := NewParser()
if err := parser.ReadContent(configFile); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
keybinds := parser.ParseKeys()
if len(keybinds) != 1 {
t.Errorf("Expected 1 keybind, got %d", len(keybinds))
}
}
func TestReadContentErrors(t *testing.T) {
tests := []struct {
name string
path string
}{
{
name: "nonexistent_directory",
path: "/nonexistent/path/that/does/not/exist",
},
{
name: "empty_directory",
path: t.TempDir(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseKeys(tt.path)
if err == nil {
t.Error("Expected error, got nil")
}
})
}
}
func TestReadContentWithTildeExpansion(t *testing.T) {
homeDir, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
}
tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0755); err != nil {
t.Skip("Cannot create test directory in home")
}
defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "config.conf")
if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
relPath, err := filepath.Rel(homeDir, tmpSubdir)
if err != nil {
t.Skip("Cannot create relative path")
}
parser := NewParser()
tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch)
if err != nil {
t.Errorf("ReadContent with tilde path failed: %v", err)
}
}
func TestEmptyAndCommentLines(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `
# This is a comment
bind=ALT,q,killclient,
# Another comment
bind=Alt,t,spawn,kitty
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
keybinds, err := ParseKeys(configFile)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(keybinds) != 2 {
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(keybinds))
}
}
func TestInvalidBindLines(t *testing.T) {
tests := []struct {
name string
line string
}{
{
name: "missing_parts",
line: "bind=SUPER,q",
},
{
name: "not_bind",
line: "blur=0",
},
{
name: "empty_line",
line: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewParser()
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if result != nil {
t.Errorf("expected nil for invalid line, got %+v", result)
}
})
}
}
func TestRealWorldConfig(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.conf")
content := `# Application Launchers
bind=Alt,t,spawn,kitty
bind=Alt,space,spawn,dms ipc call spotlight toggle
bind=Alt,v,spawn,dms ipc call clipboard toggle
# exit
bind=ALT+SHIFT,e,quit
bind=ALT,q,killclient,
# switch window focus
bind=SUPER,Tab,focusstack,next
bind=ALT,Left,focusdir,left
bind=ALT,Right,focusdir,right
# tag switch
bind=SUPER,Left,viewtoleft,0
bind=CTRL,Left,viewtoleft_have_client,0
bind=SUPER,Right,viewtoright,0
bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0
bind=Ctrl,3,view,3,0
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
keybinds, err := ParseKeys(configFile)
if err != nil {
t.Fatalf("ParseKeys failed: %v", err)
}
if len(keybinds) < 14 {
t.Errorf("Expected at least 14 keybinds, got %d", len(keybinds))
}
foundSpawn := false
foundQuit := false
foundView := false
for _, kb := range keybinds {
if kb.Command == "spawn" && kb.Params == "kitty" {
foundSpawn = true
}
if kb.Command == "quit" {
foundQuit = true
}
if kb.Command == "view" && kb.Params == "1,0" {
foundView = true
}
}
if !foundSpawn {
t.Error("Did not find spawn kitty keybind")
}
if !foundQuit {
t.Error("Did not find quit keybind")
}
if !foundView {
t.Error("Did not find view workspace 1 keybind")
}
}

View File

@@ -0,0 +1,129 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_brightness
import (
dbus "github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// MockDBusConn is an autogenerated mock type for the DBusConn type
type MockDBusConn struct {
mock.Mock
}
type MockDBusConn_Expecter struct {
mock *mock.Mock
}
func (_m *MockDBusConn) EXPECT() *MockDBusConn_Expecter {
return &MockDBusConn_Expecter{mock: &_m.Mock}
}
// Close provides a mock function with no fields
func (_m *MockDBusConn) Close() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Close")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockDBusConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
type MockDBusConn_Close_Call struct {
*mock.Call
}
// Close is a helper method to define mock.On call
func (_e *MockDBusConn_Expecter) Close() *MockDBusConn_Close_Call {
return &MockDBusConn_Close_Call{Call: _e.mock.On("Close")}
}
func (_c *MockDBusConn_Close_Call) Run(run func()) *MockDBusConn_Close_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockDBusConn_Close_Call) Return(_a0 error) *MockDBusConn_Close_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockDBusConn_Close_Call) RunAndReturn(run func() error) *MockDBusConn_Close_Call {
_c.Call.Return(run)
return _c
}
// Object provides a mock function with given fields: dest, path
func (_m *MockDBusConn) Object(dest string, path dbus.ObjectPath) dbus.BusObject {
ret := _m.Called(dest, path)
if len(ret) == 0 {
panic("no return value specified for Object")
}
var r0 dbus.BusObject
if rf, ok := ret.Get(0).(func(string, dbus.ObjectPath) dbus.BusObject); ok {
r0 = rf(dest, path)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(dbus.BusObject)
}
}
return r0
}
// MockDBusConn_Object_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Object'
type MockDBusConn_Object_Call struct {
*mock.Call
}
// Object is a helper method to define mock.On call
// - dest string
// - path dbus.ObjectPath
func (_e *MockDBusConn_Expecter) Object(dest interface{}, path interface{}) *MockDBusConn_Object_Call {
return &MockDBusConn_Object_Call{Call: _e.mock.On("Object", dest, path)}
}
func (_c *MockDBusConn_Object_Call) Run(run func(dest string, path dbus.ObjectPath)) *MockDBusConn_Object_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(dbus.ObjectPath))
})
return _c
}
func (_c *MockDBusConn_Object_Call) Return(_a0 dbus.BusObject) *MockDBusConn_Object_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockDBusConn_Object_Call) RunAndReturn(run func(string, dbus.ObjectPath) dbus.BusObject) *MockDBusConn_Object_Call {
_c.Call.Return(run)
return _c
}
// NewMockDBusConn creates a new instance of MockDBusConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDBusConn(t interface {
mock.TestingT
Cleanup(func())
}) *MockDBusConn {
mock := &MockDBusConn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,405 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_cups
import (
io "io"
ipp "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
mock "github.com/stretchr/testify/mock"
)
// MockCUPSClientInterface is an autogenerated mock type for the CUPSClientInterface type
type MockCUPSClientInterface struct {
mock.Mock
}
type MockCUPSClientInterface_Expecter struct {
mock *mock.Mock
}
func (_m *MockCUPSClientInterface) EXPECT() *MockCUPSClientInterface_Expecter {
return &MockCUPSClientInterface_Expecter{mock: &_m.Mock}
}
// CancelAllJob provides a mock function with given fields: printer, purge
func (_m *MockCUPSClientInterface) CancelAllJob(printer string, purge bool) error {
ret := _m.Called(printer, purge)
if len(ret) == 0 {
panic("no return value specified for CancelAllJob")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, bool) error); ok {
r0 = rf(printer, purge)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_CancelAllJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelAllJob'
type MockCUPSClientInterface_CancelAllJob_Call struct {
*mock.Call
}
// CancelAllJob is a helper method to define mock.On call
// - printer string
// - purge bool
func (_e *MockCUPSClientInterface_Expecter) CancelAllJob(printer interface{}, purge interface{}) *MockCUPSClientInterface_CancelAllJob_Call {
return &MockCUPSClientInterface_CancelAllJob_Call{Call: _e.mock.On("CancelAllJob", printer, purge)}
}
func (_c *MockCUPSClientInterface_CancelAllJob_Call) Run(run func(printer string, purge bool)) *MockCUPSClientInterface_CancelAllJob_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(bool))
})
return _c
}
func (_c *MockCUPSClientInterface_CancelAllJob_Call) Return(_a0 error) *MockCUPSClientInterface_CancelAllJob_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_CancelAllJob_Call) RunAndReturn(run func(string, bool) error) *MockCUPSClientInterface_CancelAllJob_Call {
_c.Call.Return(run)
return _c
}
// CancelJob provides a mock function with given fields: jobID, purge
func (_m *MockCUPSClientInterface) CancelJob(jobID int, purge bool) error {
ret := _m.Called(jobID, purge)
if len(ret) == 0 {
panic("no return value specified for CancelJob")
}
var r0 error
if rf, ok := ret.Get(0).(func(int, bool) error); ok {
r0 = rf(jobID, purge)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_CancelJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelJob'
type MockCUPSClientInterface_CancelJob_Call struct {
*mock.Call
}
// CancelJob is a helper method to define mock.On call
// - jobID int
// - purge bool
func (_e *MockCUPSClientInterface_Expecter) CancelJob(jobID interface{}, purge interface{}) *MockCUPSClientInterface_CancelJob_Call {
return &MockCUPSClientInterface_CancelJob_Call{Call: _e.mock.On("CancelJob", jobID, purge)}
}
func (_c *MockCUPSClientInterface_CancelJob_Call) Run(run func(jobID int, purge bool)) *MockCUPSClientInterface_CancelJob_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(int), args[1].(bool))
})
return _c
}
func (_c *MockCUPSClientInterface_CancelJob_Call) Return(_a0 error) *MockCUPSClientInterface_CancelJob_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_CancelJob_Call) RunAndReturn(run func(int, bool) error) *MockCUPSClientInterface_CancelJob_Call {
_c.Call.Return(run)
return _c
}
// GetJobs provides a mock function with given fields: printer, class, whichJobs, myJobs, firstJobId, limit, attributes
func (_m *MockCUPSClientInterface) GetJobs(printer string, class string, whichJobs string, myJobs bool, firstJobId int, limit int, attributes []string) (map[int]ipp.Attributes, error) {
ret := _m.Called(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
if len(ret) == 0 {
panic("no return value specified for GetJobs")
}
var r0 map[int]ipp.Attributes
var r1 error
if rf, ok := ret.Get(0).(func(string, string, string, bool, int, int, []string) (map[int]ipp.Attributes, error)); ok {
return rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
}
if rf, ok := ret.Get(0).(func(string, string, string, bool, int, int, []string) map[int]ipp.Attributes); ok {
r0 = rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int]ipp.Attributes)
}
}
if rf, ok := ret.Get(1).(func(string, string, string, bool, int, int, []string) error); ok {
r1 = rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockCUPSClientInterface_GetJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobs'
type MockCUPSClientInterface_GetJobs_Call struct {
*mock.Call
}
// GetJobs is a helper method to define mock.On call
// - printer string
// - class string
// - whichJobs string
// - myJobs bool
// - firstJobId int
// - limit int
// - attributes []string
func (_e *MockCUPSClientInterface_Expecter) GetJobs(printer interface{}, class interface{}, whichJobs interface{}, myJobs interface{}, firstJobId interface{}, limit interface{}, attributes interface{}) *MockCUPSClientInterface_GetJobs_Call {
return &MockCUPSClientInterface_GetJobs_Call{Call: _e.mock.On("GetJobs", printer, class, whichJobs, myJobs, firstJobId, limit, attributes)}
}
func (_c *MockCUPSClientInterface_GetJobs_Call) Run(run func(printer string, class string, whichJobs string, myJobs bool, firstJobId int, limit int, attributes []string)) *MockCUPSClientInterface_GetJobs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(string), args[2].(string), args[3].(bool), args[4].(int), args[5].(int), args[6].([]string))
})
return _c
}
func (_c *MockCUPSClientInterface_GetJobs_Call) Return(_a0 map[int]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetJobs_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockCUPSClientInterface_GetJobs_Call) RunAndReturn(run func(string, string, string, bool, int, int, []string) (map[int]ipp.Attributes, error)) *MockCUPSClientInterface_GetJobs_Call {
_c.Call.Return(run)
return _c
}
// GetPrinters provides a mock function with given fields: attributes
func (_m *MockCUPSClientInterface) GetPrinters(attributes []string) (map[string]ipp.Attributes, error) {
ret := _m.Called(attributes)
if len(ret) == 0 {
panic("no return value specified for GetPrinters")
}
var r0 map[string]ipp.Attributes
var r1 error
if rf, ok := ret.Get(0).(func([]string) (map[string]ipp.Attributes, error)); ok {
return rf(attributes)
}
if rf, ok := ret.Get(0).(func([]string) map[string]ipp.Attributes); ok {
r0 = rf(attributes)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]ipp.Attributes)
}
}
if rf, ok := ret.Get(1).(func([]string) error); ok {
r1 = rf(attributes)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockCUPSClientInterface_GetPrinters_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPrinters'
type MockCUPSClientInterface_GetPrinters_Call struct {
*mock.Call
}
// GetPrinters is a helper method to define mock.On call
// - attributes []string
func (_e *MockCUPSClientInterface_Expecter) GetPrinters(attributes interface{}) *MockCUPSClientInterface_GetPrinters_Call {
return &MockCUPSClientInterface_GetPrinters_Call{Call: _e.mock.On("GetPrinters", attributes)}
}
func (_c *MockCUPSClientInterface_GetPrinters_Call) Run(run func(attributes []string)) *MockCUPSClientInterface_GetPrinters_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].([]string))
})
return _c
}
func (_c *MockCUPSClientInterface_GetPrinters_Call) Return(_a0 map[string]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetPrinters_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockCUPSClientInterface_GetPrinters_Call) RunAndReturn(run func([]string) (map[string]ipp.Attributes, error)) *MockCUPSClientInterface_GetPrinters_Call {
_c.Call.Return(run)
return _c
}
// PausePrinter provides a mock function with given fields: printer
func (_m *MockCUPSClientInterface) PausePrinter(printer string) error {
ret := _m.Called(printer)
if len(ret) == 0 {
panic("no return value specified for PausePrinter")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(printer)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_PausePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PausePrinter'
type MockCUPSClientInterface_PausePrinter_Call struct {
*mock.Call
}
// PausePrinter is a helper method to define mock.On call
// - printer string
func (_e *MockCUPSClientInterface_Expecter) PausePrinter(printer interface{}) *MockCUPSClientInterface_PausePrinter_Call {
return &MockCUPSClientInterface_PausePrinter_Call{Call: _e.mock.On("PausePrinter", printer)}
}
func (_c *MockCUPSClientInterface_PausePrinter_Call) Run(run func(printer string)) *MockCUPSClientInterface_PausePrinter_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockCUPSClientInterface_PausePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_PausePrinter_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_PausePrinter_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_PausePrinter_Call {
_c.Call.Return(run)
return _c
}
// ResumePrinter provides a mock function with given fields: printer
func (_m *MockCUPSClientInterface) ResumePrinter(printer string) error {
ret := _m.Called(printer)
if len(ret) == 0 {
panic("no return value specified for ResumePrinter")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(printer)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockCUPSClientInterface_ResumePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResumePrinter'
type MockCUPSClientInterface_ResumePrinter_Call struct {
*mock.Call
}
// ResumePrinter is a helper method to define mock.On call
// - printer string
func (_e *MockCUPSClientInterface_Expecter) ResumePrinter(printer interface{}) *MockCUPSClientInterface_ResumePrinter_Call {
return &MockCUPSClientInterface_ResumePrinter_Call{Call: _e.mock.On("ResumePrinter", printer)}
}
func (_c *MockCUPSClientInterface_ResumePrinter_Call) Run(run func(printer string)) *MockCUPSClientInterface_ResumePrinter_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockCUPSClientInterface_ResumePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_ResumePrinter_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCUPSClientInterface_ResumePrinter_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_ResumePrinter_Call {
_c.Call.Return(run)
return _c
}
// SendRequest provides a mock function with given fields: url, req, additionalResponseData
func (_m *MockCUPSClientInterface) SendRequest(url string, req *ipp.Request, additionalResponseData io.Writer) (*ipp.Response, error) {
ret := _m.Called(url, req, additionalResponseData)
if len(ret) == 0 {
panic("no return value specified for SendRequest")
}
var r0 *ipp.Response
var r1 error
if rf, ok := ret.Get(0).(func(string, *ipp.Request, io.Writer) (*ipp.Response, error)); ok {
return rf(url, req, additionalResponseData)
}
if rf, ok := ret.Get(0).(func(string, *ipp.Request, io.Writer) *ipp.Response); ok {
r0 = rf(url, req, additionalResponseData)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*ipp.Response)
}
}
if rf, ok := ret.Get(1).(func(string, *ipp.Request, io.Writer) error); ok {
r1 = rf(url, req, additionalResponseData)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockCUPSClientInterface_SendRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendRequest'
type MockCUPSClientInterface_SendRequest_Call struct {
*mock.Call
}
// SendRequest is a helper method to define mock.On call
// - url string
// - req *ipp.Request
// - additionalResponseData io.Writer
func (_e *MockCUPSClientInterface_Expecter) SendRequest(url interface{}, req interface{}, additionalResponseData interface{}) *MockCUPSClientInterface_SendRequest_Call {
return &MockCUPSClientInterface_SendRequest_Call{Call: _e.mock.On("SendRequest", url, req, additionalResponseData)}
}
func (_c *MockCUPSClientInterface_SendRequest_Call) Run(run func(url string, req *ipp.Request, additionalResponseData io.Writer)) *MockCUPSClientInterface_SendRequest_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string), args[1].(*ipp.Request), args[2].(io.Writer))
})
return _c
}
func (_c *MockCUPSClientInterface_SendRequest_Call) Return(_a0 *ipp.Response, _a1 error) *MockCUPSClientInterface_SendRequest_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockCUPSClientInterface_SendRequest_Call) RunAndReturn(run func(string, *ipp.Request, io.Writer) (*ipp.Response, error)) *MockCUPSClientInterface_SendRequest_Call {
_c.Call.Return(run)
return _c
}
// NewMockCUPSClientInterface creates a new instance of MockCUPSClientInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockCUPSClientInterface(t interface {
mock.TestingT
Cleanup(func())
}) *MockCUPSClientInterface {
mock := &MockCUPSClientInterface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,689 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package gonetworkmanager
import (
gonetworkmanager "github.com/Wifx/gonetworkmanager/v2"
dbus "github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// MockAccessPoint is an autogenerated mock type for the AccessPoint type
type MockAccessPoint struct {
mock.Mock
}
type MockAccessPoint_Expecter struct {
mock *mock.Mock
}
func (_m *MockAccessPoint) EXPECT() *MockAccessPoint_Expecter {
return &MockAccessPoint_Expecter{mock: &_m.Mock}
}
// GetPath provides a mock function with no fields
func (_m *MockAccessPoint) GetPath() dbus.ObjectPath {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPath")
}
var r0 dbus.ObjectPath
if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(dbus.ObjectPath)
}
return r0
}
// MockAccessPoint_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
type MockAccessPoint_GetPath_Call struct {
*mock.Call
}
// GetPath is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPath() *MockAccessPoint_GetPath_Call {
return &MockAccessPoint_GetPath_Call{Call: _e.mock.On("GetPath")}
}
func (_c *MockAccessPoint_GetPath_Call) Run(run func()) *MockAccessPoint_GetPath_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockAccessPoint_GetPath_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAccessPoint_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockAccessPoint_GetPath_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFlags provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFlags'
type MockAccessPoint_GetPropertyFlags_Call struct {
*mock.Call
}
// GetPropertyFlags is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyFlags() *MockAccessPoint_GetPropertyFlags_Call {
return &MockAccessPoint_GetPropertyFlags_Call{Call: _e.mock.On("GetPropertyFlags")}
}
func (_c *MockAccessPoint_GetPropertyFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyFlags_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFrequency provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyFrequency() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFrequency")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyFrequency_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFrequency'
type MockAccessPoint_GetPropertyFrequency_Call struct {
*mock.Call
}
// GetPropertyFrequency is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyFrequency() *MockAccessPoint_GetPropertyFrequency_Call {
return &MockAccessPoint_GetPropertyFrequency_Call{Call: _e.mock.On("GetPropertyFrequency")}
}
func (_c *MockAccessPoint_GetPropertyFrequency_Call) Run(run func()) *MockAccessPoint_GetPropertyFrequency_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyFrequency_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyFrequency_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyFrequency_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyFrequency_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyHWAddress provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyHWAddress() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyHWAddress")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyHWAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHWAddress'
type MockAccessPoint_GetPropertyHWAddress_Call struct {
*mock.Call
}
// GetPropertyHWAddress is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyHWAddress() *MockAccessPoint_GetPropertyHWAddress_Call {
return &MockAccessPoint_GetPropertyHWAddress_Call{Call: _e.mock.On("GetPropertyHWAddress")}
}
func (_c *MockAccessPoint_GetPropertyHWAddress_Call) Run(run func()) *MockAccessPoint_GetPropertyHWAddress_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyHWAddress_Call) Return(_a0 string, _a1 error) *MockAccessPoint_GetPropertyHWAddress_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyHWAddress_Call) RunAndReturn(run func() (string, error)) *MockAccessPoint_GetPropertyHWAddress_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyLastSeen provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyLastSeen() (int32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyLastSeen")
}
var r0 int32
var r1 error
if rf, ok := ret.Get(0).(func() (int32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() int32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(int32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyLastSeen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyLastSeen'
type MockAccessPoint_GetPropertyLastSeen_Call struct {
*mock.Call
}
// GetPropertyLastSeen is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyLastSeen() *MockAccessPoint_GetPropertyLastSeen_Call {
return &MockAccessPoint_GetPropertyLastSeen_Call{Call: _e.mock.On("GetPropertyLastSeen")}
}
func (_c *MockAccessPoint_GetPropertyLastSeen_Call) Run(run func()) *MockAccessPoint_GetPropertyLastSeen_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyLastSeen_Call) Return(_a0 int32, _a1 error) *MockAccessPoint_GetPropertyLastSeen_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyLastSeen_Call) RunAndReturn(run func() (int32, error)) *MockAccessPoint_GetPropertyLastSeen_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyMaxBitrate provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyMaxBitrate() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyMaxBitrate")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyMaxBitrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMaxBitrate'
type MockAccessPoint_GetPropertyMaxBitrate_Call struct {
*mock.Call
}
// GetPropertyMaxBitrate is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyMaxBitrate() *MockAccessPoint_GetPropertyMaxBitrate_Call {
return &MockAccessPoint_GetPropertyMaxBitrate_Call{Call: _e.mock.On("GetPropertyMaxBitrate")}
}
func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) Run(run func()) *MockAccessPoint_GetPropertyMaxBitrate_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyMaxBitrate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyMaxBitrate_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyMode provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyMode() (gonetworkmanager.Nm80211Mode, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyMode")
}
var r0 gonetworkmanager.Nm80211Mode
var r1 error
if rf, ok := ret.Get(0).(func() (gonetworkmanager.Nm80211Mode, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() gonetworkmanager.Nm80211Mode); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(gonetworkmanager.Nm80211Mode)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMode'
type MockAccessPoint_GetPropertyMode_Call struct {
*mock.Call
}
// GetPropertyMode is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyMode() *MockAccessPoint_GetPropertyMode_Call {
return &MockAccessPoint_GetPropertyMode_Call{Call: _e.mock.On("GetPropertyMode")}
}
func (_c *MockAccessPoint_GetPropertyMode_Call) Run(run func()) *MockAccessPoint_GetPropertyMode_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyMode_Call) Return(_a0 gonetworkmanager.Nm80211Mode, _a1 error) *MockAccessPoint_GetPropertyMode_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyMode_Call) RunAndReturn(run func() (gonetworkmanager.Nm80211Mode, error)) *MockAccessPoint_GetPropertyMode_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyRSNFlags provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyRSNFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyRSNFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyRSNFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRSNFlags'
type MockAccessPoint_GetPropertyRSNFlags_Call struct {
*mock.Call
}
// GetPropertyRSNFlags is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyRSNFlags() *MockAccessPoint_GetPropertyRSNFlags_Call {
return &MockAccessPoint_GetPropertyRSNFlags_Call{Call: _e.mock.On("GetPropertyRSNFlags")}
}
func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyRSNFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyRSNFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyRSNFlags_Call {
_c.Call.Return(run)
return _c
}
// GetPropertySSID provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertySSID() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertySSID")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertySSID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySSID'
type MockAccessPoint_GetPropertySSID_Call struct {
*mock.Call
}
// GetPropertySSID is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertySSID() *MockAccessPoint_GetPropertySSID_Call {
return &MockAccessPoint_GetPropertySSID_Call{Call: _e.mock.On("GetPropertySSID")}
}
func (_c *MockAccessPoint_GetPropertySSID_Call) Run(run func()) *MockAccessPoint_GetPropertySSID_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertySSID_Call) Return(_a0 string, _a1 error) *MockAccessPoint_GetPropertySSID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertySSID_Call) RunAndReturn(run func() (string, error)) *MockAccessPoint_GetPropertySSID_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyStrength provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyStrength() (uint8, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyStrength")
}
var r0 uint8
var r1 error
if rf, ok := ret.Get(0).(func() (uint8, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint8); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint8)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyStrength_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyStrength'
type MockAccessPoint_GetPropertyStrength_Call struct {
*mock.Call
}
// GetPropertyStrength is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyStrength() *MockAccessPoint_GetPropertyStrength_Call {
return &MockAccessPoint_GetPropertyStrength_Call{Call: _e.mock.On("GetPropertyStrength")}
}
func (_c *MockAccessPoint_GetPropertyStrength_Call) Run(run func()) *MockAccessPoint_GetPropertyStrength_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyStrength_Call) Return(_a0 uint8, _a1 error) *MockAccessPoint_GetPropertyStrength_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyStrength_Call) RunAndReturn(run func() (uint8, error)) *MockAccessPoint_GetPropertyStrength_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyWPAFlags provides a mock function with no fields
func (_m *MockAccessPoint) GetPropertyWPAFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyWPAFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_GetPropertyWPAFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWPAFlags'
type MockAccessPoint_GetPropertyWPAFlags_Call struct {
*mock.Call
}
// GetPropertyWPAFlags is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) GetPropertyWPAFlags() *MockAccessPoint_GetPropertyWPAFlags_Call {
return &MockAccessPoint_GetPropertyWPAFlags_Call{Call: _e.mock.On("GetPropertyWPAFlags")}
}
func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyWPAFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyWPAFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyWPAFlags_Call {
_c.Call.Return(run)
return _c
}
// MarshalJSON provides a mock function with no fields
func (_m *MockAccessPoint) MarshalJSON() ([]byte, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for MarshalJSON")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func() ([]byte, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []byte); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAccessPoint_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
type MockAccessPoint_MarshalJSON_Call struct {
*mock.Call
}
// MarshalJSON is a helper method to define mock.On call
func (_e *MockAccessPoint_Expecter) MarshalJSON() *MockAccessPoint_MarshalJSON_Call {
return &MockAccessPoint_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
}
func (_c *MockAccessPoint_MarshalJSON_Call) Run(run func()) *MockAccessPoint_MarshalJSON_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAccessPoint_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockAccessPoint_MarshalJSON_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockAccessPoint_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockAccessPoint_MarshalJSON_Call {
_c.Call.Return(run)
return _c
}
// NewMockAccessPoint creates a new instance of MockAccessPoint. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAccessPoint(t interface {
mock.TestingT
Cleanup(func())
}) *MockAccessPoint {
mock := &MockAccessPoint{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,646 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package gonetworkmanager
import (
gonetworkmanager "github.com/Wifx/gonetworkmanager/v2"
dbus "github.com/godbus/dbus/v5"
mock "github.com/stretchr/testify/mock"
)
// MockConnection is an autogenerated mock type for the Connection type
type MockConnection struct {
mock.Mock
}
type MockConnection_Expecter struct {
mock *mock.Mock
}
func (_m *MockConnection) EXPECT() *MockConnection_Expecter {
return &MockConnection_Expecter{mock: &_m.Mock}
}
// ClearSecrets provides a mock function with no fields
func (_m *MockConnection) ClearSecrets() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for ClearSecrets")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_ClearSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearSecrets'
type MockConnection_ClearSecrets_Call struct {
*mock.Call
}
// ClearSecrets is a helper method to define mock.On call
func (_e *MockConnection_Expecter) ClearSecrets() *MockConnection_ClearSecrets_Call {
return &MockConnection_ClearSecrets_Call{Call: _e.mock.On("ClearSecrets")}
}
func (_c *MockConnection_ClearSecrets_Call) Run(run func()) *MockConnection_ClearSecrets_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_ClearSecrets_Call) Return(_a0 error) *MockConnection_ClearSecrets_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_ClearSecrets_Call) RunAndReturn(run func() error) *MockConnection_ClearSecrets_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with no fields
func (_m *MockConnection) Delete() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockConnection_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
func (_e *MockConnection_Expecter) Delete() *MockConnection_Delete_Call {
return &MockConnection_Delete_Call{Call: _e.mock.On("Delete")}
}
func (_c *MockConnection_Delete_Call) Run(run func()) *MockConnection_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_Delete_Call) Return(_a0 error) *MockConnection_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Delete_Call) RunAndReturn(run func() error) *MockConnection_Delete_Call {
_c.Call.Return(run)
return _c
}
// GetPath provides a mock function with no fields
func (_m *MockConnection) GetPath() dbus.ObjectPath {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPath")
}
var r0 dbus.ObjectPath
if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(dbus.ObjectPath)
}
return r0
}
// MockConnection_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
type MockConnection_GetPath_Call struct {
*mock.Call
}
// GetPath is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPath() *MockConnection_GetPath_Call {
return &MockConnection_GetPath_Call{Call: _e.mock.On("GetPath")}
}
func (_c *MockConnection_GetPath_Call) Run(run func()) *MockConnection_GetPath_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockConnection_GetPath_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockConnection_GetPath_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFilename provides a mock function with no fields
func (_m *MockConnection) GetPropertyFilename() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFilename")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetPropertyFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFilename'
type MockConnection_GetPropertyFilename_Call struct {
*mock.Call
}
// GetPropertyFilename is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPropertyFilename() *MockConnection_GetPropertyFilename_Call {
return &MockConnection_GetPropertyFilename_Call{Call: _e.mock.On("GetPropertyFilename")}
}
func (_c *MockConnection_GetPropertyFilename_Call) Run(run func()) *MockConnection_GetPropertyFilename_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPropertyFilename_Call) Return(_a0 string, _a1 error) *MockConnection_GetPropertyFilename_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetPropertyFilename_Call) RunAndReturn(run func() (string, error)) *MockConnection_GetPropertyFilename_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyFlags provides a mock function with no fields
func (_m *MockConnection) GetPropertyFlags() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyFlags")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetPropertyFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFlags'
type MockConnection_GetPropertyFlags_Call struct {
*mock.Call
}
// GetPropertyFlags is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPropertyFlags() *MockConnection_GetPropertyFlags_Call {
return &MockConnection_GetPropertyFlags_Call{Call: _e.mock.On("GetPropertyFlags")}
}
func (_c *MockConnection_GetPropertyFlags_Call) Run(run func()) *MockConnection_GetPropertyFlags_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPropertyFlags_Call) Return(_a0 uint32, _a1 error) *MockConnection_GetPropertyFlags_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetPropertyFlags_Call) RunAndReturn(run func() (uint32, error)) *MockConnection_GetPropertyFlags_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyUnsaved provides a mock function with no fields
func (_m *MockConnection) GetPropertyUnsaved() (bool, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyUnsaved")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func() (bool, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetPropertyUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUnsaved'
type MockConnection_GetPropertyUnsaved_Call struct {
*mock.Call
}
// GetPropertyUnsaved is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetPropertyUnsaved() *MockConnection_GetPropertyUnsaved_Call {
return &MockConnection_GetPropertyUnsaved_Call{Call: _e.mock.On("GetPropertyUnsaved")}
}
func (_c *MockConnection_GetPropertyUnsaved_Call) Run(run func()) *MockConnection_GetPropertyUnsaved_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetPropertyUnsaved_Call) Return(_a0 bool, _a1 error) *MockConnection_GetPropertyUnsaved_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetPropertyUnsaved_Call) RunAndReturn(run func() (bool, error)) *MockConnection_GetPropertyUnsaved_Call {
_c.Call.Return(run)
return _c
}
// GetSecrets provides a mock function with given fields: settingName
func (_m *MockConnection) GetSecrets(settingName string) (gonetworkmanager.ConnectionSettings, error) {
ret := _m.Called(settingName)
if len(ret) == 0 {
panic("no return value specified for GetSecrets")
}
var r0 gonetworkmanager.ConnectionSettings
var r1 error
if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.ConnectionSettings, error)); ok {
return rf(settingName)
}
if rf, ok := ret.Get(0).(func(string) gonetworkmanager.ConnectionSettings); ok {
r0 = rf(settingName)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(gonetworkmanager.ConnectionSettings)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(settingName)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSecrets'
type MockConnection_GetSecrets_Call struct {
*mock.Call
}
// GetSecrets is a helper method to define mock.On call
// - settingName string
func (_e *MockConnection_Expecter) GetSecrets(settingName interface{}) *MockConnection_GetSecrets_Call {
return &MockConnection_GetSecrets_Call{Call: _e.mock.On("GetSecrets", settingName)}
}
func (_c *MockConnection_GetSecrets_Call) Run(run func(settingName string)) *MockConnection_GetSecrets_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockConnection_GetSecrets_Call) Return(_a0 gonetworkmanager.ConnectionSettings, _a1 error) *MockConnection_GetSecrets_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetSecrets_Call) RunAndReturn(run func(string) (gonetworkmanager.ConnectionSettings, error)) *MockConnection_GetSecrets_Call {
_c.Call.Return(run)
return _c
}
// GetSettings provides a mock function with no fields
func (_m *MockConnection) GetSettings() (gonetworkmanager.ConnectionSettings, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSettings")
}
var r0 gonetworkmanager.ConnectionSettings
var r1 error
if rf, ok := ret.Get(0).(func() (gonetworkmanager.ConnectionSettings, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() gonetworkmanager.ConnectionSettings); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(gonetworkmanager.ConnectionSettings)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_GetSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSettings'
type MockConnection_GetSettings_Call struct {
*mock.Call
}
// GetSettings is a helper method to define mock.On call
func (_e *MockConnection_Expecter) GetSettings() *MockConnection_GetSettings_Call {
return &MockConnection_GetSettings_Call{Call: _e.mock.On("GetSettings")}
}
func (_c *MockConnection_GetSettings_Call) Run(run func()) *MockConnection_GetSettings_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_GetSettings_Call) Return(_a0 gonetworkmanager.ConnectionSettings, _a1 error) *MockConnection_GetSettings_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_GetSettings_Call) RunAndReturn(run func() (gonetworkmanager.ConnectionSettings, error)) *MockConnection_GetSettings_Call {
_c.Call.Return(run)
return _c
}
// MarshalJSON provides a mock function with no fields
func (_m *MockConnection) MarshalJSON() ([]byte, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for MarshalJSON")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func() ([]byte, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []byte); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConnection_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
type MockConnection_MarshalJSON_Call struct {
*mock.Call
}
// MarshalJSON is a helper method to define mock.On call
func (_e *MockConnection_Expecter) MarshalJSON() *MockConnection_MarshalJSON_Call {
return &MockConnection_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
}
func (_c *MockConnection_MarshalJSON_Call) Run(run func()) *MockConnection_MarshalJSON_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockConnection_MarshalJSON_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockConnection_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockConnection_MarshalJSON_Call {
_c.Call.Return(run)
return _c
}
// Save provides a mock function with no fields
func (_m *MockConnection) Save() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Save")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
type MockConnection_Save_Call struct {
*mock.Call
}
// Save is a helper method to define mock.On call
func (_e *MockConnection_Expecter) Save() *MockConnection_Save_Call {
return &MockConnection_Save_Call{Call: _e.mock.On("Save")}
}
func (_c *MockConnection_Save_Call) Run(run func()) *MockConnection_Save_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockConnection_Save_Call) Return(_a0 error) *MockConnection_Save_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Save_Call) RunAndReturn(run func() error) *MockConnection_Save_Call {
_c.Call.Return(run)
return _c
}
// Update provides a mock function with given fields: settings
func (_m *MockConnection) Update(settings gonetworkmanager.ConnectionSettings) error {
ret := _m.Called(settings)
if len(ret) == 0 {
panic("no return value specified for Update")
}
var r0 error
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) error); ok {
r0 = rf(settings)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
type MockConnection_Update_Call struct {
*mock.Call
}
// Update is a helper method to define mock.On call
// - settings gonetworkmanager.ConnectionSettings
func (_e *MockConnection_Expecter) Update(settings interface{}) *MockConnection_Update_Call {
return &MockConnection_Update_Call{Call: _e.mock.On("Update", settings)}
}
func (_c *MockConnection_Update_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockConnection_Update_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(gonetworkmanager.ConnectionSettings))
})
return _c
}
func (_c *MockConnection_Update_Call) Return(_a0 error) *MockConnection_Update_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_Update_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) error) *MockConnection_Update_Call {
_c.Call.Return(run)
return _c
}
// UpdateUnsaved provides a mock function with given fields: settings
func (_m *MockConnection) UpdateUnsaved(settings gonetworkmanager.ConnectionSettings) error {
ret := _m.Called(settings)
if len(ret) == 0 {
panic("no return value specified for UpdateUnsaved")
}
var r0 error
if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) error); ok {
r0 = rf(settings)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConnection_UpdateUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUnsaved'
type MockConnection_UpdateUnsaved_Call struct {
*mock.Call
}
// UpdateUnsaved is a helper method to define mock.On call
// - settings gonetworkmanager.ConnectionSettings
func (_e *MockConnection_Expecter) UpdateUnsaved(settings interface{}) *MockConnection_UpdateUnsaved_Call {
return &MockConnection_UpdateUnsaved_Call{Call: _e.mock.On("UpdateUnsaved", settings)}
}
func (_c *MockConnection_UpdateUnsaved_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockConnection_UpdateUnsaved_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(gonetworkmanager.ConnectionSettings))
})
return _c
}
func (_c *MockConnection_UpdateUnsaved_Call) Return(_a0 error) *MockConnection_UpdateUnsaved_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockConnection_UpdateUnsaved_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) error) *MockConnection_UpdateUnsaved_Call {
_c.Call.Return(run)
return _c
}
// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockConnection(t interface {
mock.TestingT
Cleanup(func())
}) *MockConnection {
mock := &MockConnection{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,772 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package gonetworkmanager
import (
gonetworkmanager "github.com/Wifx/gonetworkmanager/v2"
mock "github.com/stretchr/testify/mock"
)
// MockIP4Config is an autogenerated mock type for the IP4Config type
type MockIP4Config struct {
mock.Mock
}
type MockIP4Config_Expecter struct {
mock *mock.Mock
}
func (_m *MockIP4Config) EXPECT() *MockIP4Config_Expecter {
return &MockIP4Config_Expecter{mock: &_m.Mock}
}
// GetPropertyAddressData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyAddressData() ([]gonetworkmanager.IP4AddressData, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyAddressData")
}
var r0 []gonetworkmanager.IP4AddressData
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4AddressData, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4AddressData); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4AddressData)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyAddressData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAddressData'
type MockIP4Config_GetPropertyAddressData_Call struct {
*mock.Call
}
// GetPropertyAddressData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyAddressData() *MockIP4Config_GetPropertyAddressData_Call {
return &MockIP4Config_GetPropertyAddressData_Call{Call: _e.mock.On("GetPropertyAddressData")}
}
func (_c *MockIP4Config_GetPropertyAddressData_Call) Run(run func()) *MockIP4Config_GetPropertyAddressData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyAddressData_Call) Return(_a0 []gonetworkmanager.IP4AddressData, _a1 error) *MockIP4Config_GetPropertyAddressData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyAddressData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4AddressData, error)) *MockIP4Config_GetPropertyAddressData_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyAddresses provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyAddresses() ([]gonetworkmanager.IP4Address, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyAddresses")
}
var r0 []gonetworkmanager.IP4Address
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4Address, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4Address); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4Address)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyAddresses_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAddresses'
type MockIP4Config_GetPropertyAddresses_Call struct {
*mock.Call
}
// GetPropertyAddresses is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyAddresses() *MockIP4Config_GetPropertyAddresses_Call {
return &MockIP4Config_GetPropertyAddresses_Call{Call: _e.mock.On("GetPropertyAddresses")}
}
func (_c *MockIP4Config_GetPropertyAddresses_Call) Run(run func()) *MockIP4Config_GetPropertyAddresses_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyAddresses_Call) Return(_a0 []gonetworkmanager.IP4Address, _a1 error) *MockIP4Config_GetPropertyAddresses_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyAddresses_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4Address, error)) *MockIP4Config_GetPropertyAddresses_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyDnsOptions provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyDnsOptions() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyDnsOptions")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyDnsOptions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDnsOptions'
type MockIP4Config_GetPropertyDnsOptions_Call struct {
*mock.Call
}
// GetPropertyDnsOptions is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyDnsOptions() *MockIP4Config_GetPropertyDnsOptions_Call {
return &MockIP4Config_GetPropertyDnsOptions_Call{Call: _e.mock.On("GetPropertyDnsOptions")}
}
func (_c *MockIP4Config_GetPropertyDnsOptions_Call) Run(run func()) *MockIP4Config_GetPropertyDnsOptions_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyDnsOptions_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyDnsOptions_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyDnsOptions_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyDnsOptions_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyDnsPriority provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyDnsPriority() (uint32, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyDnsPriority")
}
var r0 uint32
var r1 error
if rf, ok := ret.Get(0).(func() (uint32, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() uint32); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(uint32)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyDnsPriority_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDnsPriority'
type MockIP4Config_GetPropertyDnsPriority_Call struct {
*mock.Call
}
// GetPropertyDnsPriority is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyDnsPriority() *MockIP4Config_GetPropertyDnsPriority_Call {
return &MockIP4Config_GetPropertyDnsPriority_Call{Call: _e.mock.On("GetPropertyDnsPriority")}
}
func (_c *MockIP4Config_GetPropertyDnsPriority_Call) Run(run func()) *MockIP4Config_GetPropertyDnsPriority_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyDnsPriority_Call) Return(_a0 uint32, _a1 error) *MockIP4Config_GetPropertyDnsPriority_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyDnsPriority_Call) RunAndReturn(run func() (uint32, error)) *MockIP4Config_GetPropertyDnsPriority_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyDomains provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyDomains() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyDomains")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDomains'
type MockIP4Config_GetPropertyDomains_Call struct {
*mock.Call
}
// GetPropertyDomains is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyDomains() *MockIP4Config_GetPropertyDomains_Call {
return &MockIP4Config_GetPropertyDomains_Call{Call: _e.mock.On("GetPropertyDomains")}
}
func (_c *MockIP4Config_GetPropertyDomains_Call) Run(run func()) *MockIP4Config_GetPropertyDomains_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyDomains_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyDomains_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyDomains_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyDomains_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyGateway provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyGateway() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyGateway")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyGateway_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyGateway'
type MockIP4Config_GetPropertyGateway_Call struct {
*mock.Call
}
// GetPropertyGateway is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyGateway() *MockIP4Config_GetPropertyGateway_Call {
return &MockIP4Config_GetPropertyGateway_Call{Call: _e.mock.On("GetPropertyGateway")}
}
func (_c *MockIP4Config_GetPropertyGateway_Call) Run(run func()) *MockIP4Config_GetPropertyGateway_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyGateway_Call) Return(_a0 string, _a1 error) *MockIP4Config_GetPropertyGateway_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyGateway_Call) RunAndReturn(run func() (string, error)) *MockIP4Config_GetPropertyGateway_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyNameserverData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyNameserverData() ([]gonetworkmanager.IP4NameserverData, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyNameserverData")
}
var r0 []gonetworkmanager.IP4NameserverData
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4NameserverData, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4NameserverData); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4NameserverData)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyNameserverData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNameserverData'
type MockIP4Config_GetPropertyNameserverData_Call struct {
*mock.Call
}
// GetPropertyNameserverData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyNameserverData() *MockIP4Config_GetPropertyNameserverData_Call {
return &MockIP4Config_GetPropertyNameserverData_Call{Call: _e.mock.On("GetPropertyNameserverData")}
}
func (_c *MockIP4Config_GetPropertyNameserverData_Call) Run(run func()) *MockIP4Config_GetPropertyNameserverData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyNameserverData_Call) Return(_a0 []gonetworkmanager.IP4NameserverData, _a1 error) *MockIP4Config_GetPropertyNameserverData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyNameserverData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4NameserverData, error)) *MockIP4Config_GetPropertyNameserverData_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyNameservers provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyNameservers() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyNameservers")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyNameservers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNameservers'
type MockIP4Config_GetPropertyNameservers_Call struct {
*mock.Call
}
// GetPropertyNameservers is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyNameservers() *MockIP4Config_GetPropertyNameservers_Call {
return &MockIP4Config_GetPropertyNameservers_Call{Call: _e.mock.On("GetPropertyNameservers")}
}
func (_c *MockIP4Config_GetPropertyNameservers_Call) Run(run func()) *MockIP4Config_GetPropertyNameservers_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyNameservers_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyNameservers_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyNameservers_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyNameservers_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyRouteData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyRouteData() ([]gonetworkmanager.IP4RouteData, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyRouteData")
}
var r0 []gonetworkmanager.IP4RouteData
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4RouteData, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4RouteData); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4RouteData)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyRouteData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRouteData'
type MockIP4Config_GetPropertyRouteData_Call struct {
*mock.Call
}
// GetPropertyRouteData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyRouteData() *MockIP4Config_GetPropertyRouteData_Call {
return &MockIP4Config_GetPropertyRouteData_Call{Call: _e.mock.On("GetPropertyRouteData")}
}
func (_c *MockIP4Config_GetPropertyRouteData_Call) Run(run func()) *MockIP4Config_GetPropertyRouteData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyRouteData_Call) Return(_a0 []gonetworkmanager.IP4RouteData, _a1 error) *MockIP4Config_GetPropertyRouteData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyRouteData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4RouteData, error)) *MockIP4Config_GetPropertyRouteData_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyRoutes provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyRoutes() ([]gonetworkmanager.IP4Route, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyRoutes")
}
var r0 []gonetworkmanager.IP4Route
var r1 error
if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4Route, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4Route); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]gonetworkmanager.IP4Route)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRoutes'
type MockIP4Config_GetPropertyRoutes_Call struct {
*mock.Call
}
// GetPropertyRoutes is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyRoutes() *MockIP4Config_GetPropertyRoutes_Call {
return &MockIP4Config_GetPropertyRoutes_Call{Call: _e.mock.On("GetPropertyRoutes")}
}
func (_c *MockIP4Config_GetPropertyRoutes_Call) Run(run func()) *MockIP4Config_GetPropertyRoutes_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyRoutes_Call) Return(_a0 []gonetworkmanager.IP4Route, _a1 error) *MockIP4Config_GetPropertyRoutes_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyRoutes_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4Route, error)) *MockIP4Config_GetPropertyRoutes_Call {
_c.Call.Return(run)
return _c
}
// GetPropertySearches provides a mock function with no fields
func (_m *MockIP4Config) GetPropertySearches() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertySearches")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertySearches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySearches'
type MockIP4Config_GetPropertySearches_Call struct {
*mock.Call
}
// GetPropertySearches is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertySearches() *MockIP4Config_GetPropertySearches_Call {
return &MockIP4Config_GetPropertySearches_Call{Call: _e.mock.On("GetPropertySearches")}
}
func (_c *MockIP4Config_GetPropertySearches_Call) Run(run func()) *MockIP4Config_GetPropertySearches_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertySearches_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertySearches_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertySearches_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertySearches_Call {
_c.Call.Return(run)
return _c
}
// GetPropertyWinsServerData provides a mock function with no fields
func (_m *MockIP4Config) GetPropertyWinsServerData() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPropertyWinsServerData")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_GetPropertyWinsServerData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWinsServerData'
type MockIP4Config_GetPropertyWinsServerData_Call struct {
*mock.Call
}
// GetPropertyWinsServerData is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) GetPropertyWinsServerData() *MockIP4Config_GetPropertyWinsServerData_Call {
return &MockIP4Config_GetPropertyWinsServerData_Call{Call: _e.mock.On("GetPropertyWinsServerData")}
}
func (_c *MockIP4Config_GetPropertyWinsServerData_Call) Run(run func()) *MockIP4Config_GetPropertyWinsServerData_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_GetPropertyWinsServerData_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyWinsServerData_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_GetPropertyWinsServerData_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyWinsServerData_Call {
_c.Call.Return(run)
return _c
}
// MarshalJSON provides a mock function with no fields
func (_m *MockIP4Config) MarshalJSON() ([]byte, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for MarshalJSON")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func() ([]byte, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []byte); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockIP4Config_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
type MockIP4Config_MarshalJSON_Call struct {
*mock.Call
}
// MarshalJSON is a helper method to define mock.On call
func (_e *MockIP4Config_Expecter) MarshalJSON() *MockIP4Config_MarshalJSON_Call {
return &MockIP4Config_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
}
func (_c *MockIP4Config_MarshalJSON_Call) Run(run func()) *MockIP4Config_MarshalJSON_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockIP4Config_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockIP4Config_MarshalJSON_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockIP4Config_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockIP4Config_MarshalJSON_Call {
_c.Call.Return(run)
return _c
}
// NewMockIP4Config creates a new instance of MockIP4Config. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockIP4Config(t interface {
mock.TestingT
Cleanup(func())
}) *MockIP4Config {
mock := &MockIP4Config{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

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