diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 72b9f755..e82b70bc 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -1,5 +1,8 @@
#!/bin/bash
+# DISABLED for now
+exit 0
+
set -euo pipefail
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
diff --git a/.gitignore b/.gitignore
index 0b16b9ee..e8c2929f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -101,4 +101,41 @@ go.work.sum
# Editor/IDE
# .idea/
-# .vscode/
\ No newline at end of file
+# .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
diff --git a/README.md b/README.md
index 39aaa515..46409041 100644
--- a/README.md
+++ b/README.md
@@ -1,219 +1 @@
-# DankMaterialShell (dms)
-
-
-
-
-
-
- ### A modern Wayland desktop shell
-
- Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
-
-[](https://danklinux.com/docs)
-[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
-[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
-[](https://github.com/AvengeMedia/DankMaterialShell/releases)
-[](https://aur.archlinux.org/packages/dms-shell-bin)
-[)](https://aur.archlinux.org/packages/dms-shell-git)
-[](https://ko-fi.com/avengemediallc)
-
-
-
-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
-
-
-
-https://github.com/user-attachments/assets/1200a739-7770-4601-8b85-695ca527819a
-
-
-
-More Screenshots
-
-
-
-
-
----
-
-## 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.
+# TODO
diff --git a/backend/LICENSE b/backend/LICENSE
new file mode 100644
index 00000000..d8152be0
--- /dev/null
+++ b/backend/LICENSE
@@ -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.
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 00000000..ef6778f7
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,310 @@
+
+
+
+
+
+ ### dms CLI & Backend + dankinstall
+
+[](https://danklinux.com/docs)
+[](https://github.com/AvengeMedia/DankMaterialShell/backend/releases)
+[](https://github.com/AvengeMedia/DankMaterialShell/backend/blob/master/LICENSE)
+
+
+
+---
+
+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 ` - 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 ` - Send IPC commands to running shell
\ No newline at end of file
diff --git a/backend/assets/dank.svg b/backend/assets/dank.svg
new file mode 100644
index 00000000..3ce27f74
--- /dev/null
+++ b/backend/assets/dank.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/danklogo2.svg b/backend/assets/danklogo.svg
similarity index 100%
rename from assets/danklogo2.svg
rename to backend/assets/danklogo.svg
diff --git a/backend/build_dankinstall.sh b/backend/build_dankinstall.sh
new file mode 100755
index 00000000..c430e735
--- /dev/null
+++ b/backend/build_dankinstall.sh
@@ -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-*
diff --git a/backend/cmd/dms/commands_brightness.go b/backend/cmd/dms/commands_brightness.go
new file mode 100644
index 00000000..5570e57b
--- /dev/null
+++ b/backend/cmd/dms/commands_brightness.go
@@ -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 ",
+ 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 ",
+ 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)
+}
diff --git a/backend/cmd/dms/commands_common.go b/backend/cmd/dms/commands_common.go
new file mode 100644
index 00000000..7873ff75
--- /dev/null
+++ b/backend/cmd/dms/commands_common.go
@@ -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 ",
+ 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 )",
+ 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 ",
+ 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 ",
+ 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,
+ }
+}
diff --git a/backend/cmd/dms/commands_dank16.go b/backend/cmd/dms/commands_dank16.go
new file mode 100644
index 00000000..bfb2d8fb
--- /dev/null
+++ b/backend/cmd/dms/commands_dank16.go
@@ -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 ",
+ 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))
+ }
+}
diff --git a/backend/cmd/dms/commands_features.go b/backend/cmd/dms/commands_features.go
new file mode 100644
index 00000000..335ab2be
--- /dev/null
+++ b/backend/cmd/dms/commands_features.go
@@ -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
+}
diff --git a/backend/cmd/dms/commands_greeter.go b/backend/cmd/dms/commands_greeter.go
new file mode 100644
index 00000000..d24f6ca3
--- /dev/null
+++ b/backend/cmd/dms/commands_greeter.go
@@ -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
+}
diff --git a/backend/cmd/dms/commands_keybinds.go b/backend/cmd/dms/commands_keybinds.go
new file mode 100644
index 00000000..ddd73947
--- /dev/null
+++ b/backend/cmd/dms/commands_keybinds.go
@@ -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 ",
+ 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))
+}
diff --git a/backend/cmd/dms/commands_root.go b/backend/cmd/dms/commands_root.go
new file mode 100644
index 00000000..dc5f2437
--- /dev/null
+++ b/backend/cmd/dms/commands_root.go
@@ -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)
+ }
+}
diff --git a/backend/cmd/dms/commands_setup.go b/backend/cmd/dms/commands_setup.go
new file mode 100644
index 00000000..5e58d61f
--- /dev/null
+++ b/backend/cmd/dms/commands_setup.go
@@ -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
+}
diff --git a/backend/cmd/dms/main.go b/backend/cmd/dms/main.go
new file mode 100644
index 00000000..a65ab1f7
--- /dev/null
+++ b/backend/cmd/dms/main.go
@@ -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)
+ }
+}
diff --git a/backend/cmd/dms/main_distro.go b/backend/cmd/dms/main_distro.go
new file mode 100644
index 00000000..a0a03132
--- /dev/null
+++ b/backend/cmd/dms/main_distro.go
@@ -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)
+ }
+}
diff --git a/backend/cmd/dms/shell.go b/backend/cmd/dms/shell.go
new file mode 100644
index 00000000..b9bd0e1e
--- /dev/null
+++ b/backend/cmd/dms/shell.go
@@ -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 [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)
+ }
+}
diff --git a/backend/cmd/dms/ui.go b/backend/cmd/dms/ui.go
new file mode 100644
index 00000000..ed20ac81
--- /dev/null
+++ b/backend/cmd/dms/ui.go
@@ -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}}
+`
+}
diff --git a/backend/cmd/dms/utils.go b/backend/cmd/dms/utils.go
new file mode 100644
index 00000000..fe9dceef
--- /dev/null
+++ b/backend/cmd/dms/utils.go
@@ -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
+}
diff --git a/backend/flake.lock b/backend/flake.lock
new file mode 100644
index 00000000..17542b59
--- /dev/null
+++ b/backend/flake.lock
@@ -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
+}
diff --git a/backend/flake.nix b/backend/flake.nix
new file mode 100644
index 00000000..c62ce1d0
--- /dev/null
+++ b/backend/flake.nix
@@ -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;
+ }
+ );
+ };
+}
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 00000000..ba864610
--- /dev/null
+++ b/backend/go.mod
@@ -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
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 00000000..cc847924
--- /dev/null
+++ b/backend/go.sum
@@ -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=
diff --git a/backend/install.sh b/backend/install.sh
new file mode 100755
index 00000000..83d92c17
--- /dev/null
+++ b/backend/install.sh
@@ -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"
\ No newline at end of file
diff --git a/backend/internal/config/deployer.go b/backend/internal/config/deployer.go
new file mode 100644
index 00000000..823c1664
--- /dev/null
+++ b/backend/internal/config/deployer.go
@@ -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
+}
diff --git a/backend/internal/config/deployer_test.go b/backend/internal/config/deployer_test.go
new file mode 100644
index 00000000..92df7b5d
--- /dev/null
+++ b/backend/internal/config/deployer_test.go
@@ -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\"")
+ })
+}
diff --git a/backend/internal/config/dms.go b/backend/internal/config/dms.go
new file mode 100644
index 00000000..2ecc3ef2
--- /dev/null
+++ b/backend/internal/config/dms.go
@@ -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")
+}
diff --git a/backend/internal/config/embedded/alacritty-theme.toml b/backend/internal/config/embedded/alacritty-theme.toml
new file mode 100644
index 00000000..40484574
--- /dev/null
+++ b/backend/internal/config/embedded/alacritty-theme.toml
@@ -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'
diff --git a/backend/internal/config/embedded/alacritty.toml b/backend/internal/config/embedded/alacritty.toml
new file mode 100644
index 00000000..b666177e
--- /dev/null
+++ b/backend/internal/config/embedded/alacritty.toml
@@ -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" },
+]
diff --git a/backend/internal/config/embedded/ghostty-colors.conf b/backend/internal/config/embedded/ghostty-colors.conf
new file mode 100644
index 00000000..56dca5f3
--- /dev/null
+++ b/backend/internal/config/embedded/ghostty-colors.conf
@@ -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
diff --git a/backend/internal/config/embedded/ghostty.conf b/backend/internal/config/embedded/ghostty.conf
new file mode 100644
index 00000000..f5412374
--- /dev/null
+++ b/backend/internal/config/embedded/ghostty.conf
@@ -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
diff --git a/backend/internal/config/embedded/hyprland.conf b/backend/internal/config/embedded/hyprland.conf
new file mode 100644
index 00000000..f44208c9
--- /dev/null
+++ b/backend/internal/config/embedded/hyprland.conf
@@ -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
diff --git a/backend/internal/config/embedded/kitty-tabs.conf b/backend/internal/config/embedded/kitty-tabs.conf
new file mode 100644
index 00000000..b2305c84
--- /dev/null
+++ b/backend/internal/config/embedded/kitty-tabs.conf
@@ -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}]"
diff --git a/backend/internal/config/embedded/kitty-theme.conf b/backend/internal/config/embedded/kitty-theme.conf
new file mode 100644
index 00000000..2587bcf6
--- /dev/null
+++ b/backend/internal/config/embedded/kitty-theme.conf
@@ -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
diff --git a/backend/internal/config/embedded/kitty.conf b/backend/internal/config/embedded/kitty.conf
new file mode 100644
index 00000000..2ae96a02
--- /dev/null
+++ b/backend/internal/config/embedded/kitty.conf
@@ -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
diff --git a/backend/internal/config/embedded/niri.kdl b/backend/internal/config/embedded/niri.kdl
new file mode 100644
index 00000000..1faa139a
--- /dev/null
+++ b/backend/internal/config/embedded/niri.kdl
@@ -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
+}
diff --git a/backend/internal/config/hyprland.go b/backend/internal/config/hyprland.go
new file mode 100644
index 00000000..e5729554
--- /dev/null
+++ b/backend/internal/config/hyprland.go
@@ -0,0 +1,6 @@
+package config
+
+import _ "embed"
+
+//go:embed embedded/hyprland.conf
+var HyprlandConfig string
diff --git a/backend/internal/config/niri.go b/backend/internal/config/niri.go
new file mode 100644
index 00000000..78fb03ae
--- /dev/null
+++ b/backend/internal/config/niri.go
@@ -0,0 +1,6 @@
+package config
+
+import _ "embed"
+
+//go:embed embedded/niri.kdl
+var NiriConfig string
diff --git a/backend/internal/config/terminals.go b/backend/internal/config/terminals.go
new file mode 100644
index 00000000..a5bba04c
--- /dev/null
+++ b/backend/internal/config/terminals.go
@@ -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
diff --git a/backend/internal/dank16/dank16.go b/backend/internal/dank16/dank16.go
new file mode 100644
index 00000000..4140eb00
--- /dev/null
+++ b/backend/internal/dank16/dank16.go
@@ -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
+}
diff --git a/backend/internal/dank16/dank16_test.go b/backend/internal/dank16/dank16_test.go
new file mode 100644
index 00000000..4b6f167f
--- /dev/null
+++ b/backend/internal/dank16/dank16_test.go
@@ -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)
+}
diff --git a/backend/internal/dank16/terminals.go b/backend/internal/dank16/terminals.go
new file mode 100644
index 00000000..d2290b6a
--- /dev/null
+++ b/backend/internal/dank16/terminals.go
@@ -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()
+}
diff --git a/backend/internal/dank16/vscode.go b/backend/internal/dank16/vscode.go
new file mode 100644
index 00000000..cd4d4d7d
--- /dev/null
+++ b/backend/internal/dank16/vscode.go
@@ -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, "", " ")
+}
diff --git a/backend/internal/deps/detector.go b/backend/internal/deps/detector.go
new file mode 100644
index 00000000..e4f54a7c
--- /dev/null
+++ b/backend/internal/deps/detector.go
@@ -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)
+}
diff --git a/backend/internal/distros/arch.go b/backend/internal/distros/arch.go
new file mode 100644
index 00000000..b778c264
--- /dev/null
+++ b/backend/internal/distros/arch.go
@@ -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
+}
diff --git a/backend/internal/distros/base.go b/backend/internal/distros/base.go
new file mode 100644
index 00000000..3d6eaa69
--- /dev/null
+++ b/backend/internal/distros/base.go
@@ -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
+}
diff --git a/backend/internal/distros/base_test.go b/backend/internal/distros/base_test.go
new file mode 100644
index 00000000..5cbcb769
--- /dev/null
+++ b/backend/internal/distros/base_test.go
@@ -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)
+ }
+ }
+}
diff --git a/backend/internal/distros/debian.go b/backend/internal/distros/debian.go
new file mode 100644
index 00000000..fb12765a
--- /dev/null
+++ b/backend/internal/distros/debian.go
@@ -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
+}
diff --git a/backend/internal/distros/factory.go b/backend/internal/distros/factory.go
new file mode 100644
index 00000000..fb5ecf94
--- /dev/null
+++ b/backend/internal/distros/factory.go
@@ -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)
+}
diff --git a/backend/internal/distros/fedora.go b/backend/internal/distros/fedora.go
new file mode 100644
index 00000000..f9a601c7
--- /dev/null
+++ b/backend/internal/distros/fedora.go
@@ -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)
+}
diff --git a/backend/internal/distros/gentoo.go b/backend/internal/distros/gentoo.go
new file mode 100644
index 00000000..b7281c47
--- /dev/null
+++ b/backend/internal/distros/gentoo.go
@@ -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)
+}
diff --git a/backend/internal/distros/interface.go b/backend/internal/distros/interface.go
new file mode 100644
index 00000000..5256d3c8
--- /dev/null
+++ b/backend/internal/distros/interface.go
@@ -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
+}
diff --git a/backend/internal/distros/manual_packages.go b/backend/internal/distros/manual_packages.go
new file mode 100644
index 00000000..316f9a22
--- /dev/null
+++ b/backend/internal/distros/manual_packages.go
@@ -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
+}
diff --git a/backend/internal/distros/manual_packages_test.go b/backend/internal/distros/manual_packages_test.go
new file mode 100644
index 00000000..4d9fc628
--- /dev/null
+++ b/backend/internal/distros/manual_packages_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/distros/nixos.go b/backend/internal/distros/nixos.go
new file mode 100644
index 00000000..0284e741
--- /dev/null
+++ b/backend/internal/distros/nixos.go
@@ -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
+}
diff --git a/backend/internal/distros/opensuse.go b/backend/internal/distros/opensuse.go
new file mode 100644
index 00000000..e9936235
--- /dev/null
+++ b/backend/internal/distros/opensuse.go
@@ -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
+}
diff --git a/backend/internal/distros/osinfo.go b/backend/internal/distros/osinfo.go
new file mode 100644
index 00000000..6a6e4422
--- /dev/null
+++ b/backend/internal/distros/osinfo.go
@@ -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
+}
diff --git a/backend/internal/distros/ubuntu.go b/backend/internal/distros/ubuntu.go
new file mode 100644
index 00000000..741f5882
--- /dev/null
+++ b/backend/internal/distros/ubuntu.go
@@ -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
+}
diff --git a/backend/internal/dms/app.go b/backend/internal/dms/app.go
new file mode 100644
index 00000000..2f7fb8cc
--- /dev/null
+++ b/backend/internal/dms/app.go
@@ -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 /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()
+ }
+}
diff --git a/backend/internal/dms/app_distro.go b/backend/internal/dms/app_distro.go
new file mode 100644
index 00000000..2875d2b9
--- /dev/null
+++ b/backend/internal/dms/app_distro.go
@@ -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()
+ }
+}
diff --git a/backend/internal/dms/detector.go b/backend/internal/dms/detector.go
new file mode 100644
index 00000000..7c3d528b
--- /dev/null
+++ b/backend/internal/dms/detector.go
@@ -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
+}
diff --git a/backend/internal/dms/handlers_common.go b/backend/internal/dms/handlers_common.go
new file mode 100644
index 00000000..764a9b54
--- /dev/null
+++ b/backend/internal/dms/handlers_common.go
@@ -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()
+}
diff --git a/backend/internal/dms/handlers_features.go b/backend/internal/dms/handlers_features.go
new file mode 100644
index 00000000..cef1a9e4
--- /dev/null
+++ b/backend/internal/dms/handlers_features.go
@@ -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
+}
diff --git a/backend/internal/dms/handlers_mainmenu.go b/backend/internal/dms/handlers_mainmenu.go
new file mode 100644
index 00000000..6a2b9416
--- /dev/null
+++ b/backend/internal/dms/handlers_mainmenu.go
@@ -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
+}
diff --git a/backend/internal/dms/handlers_mainmenu_distro.go b/backend/internal/dms/handlers_mainmenu_distro.go
new file mode 100644
index 00000000..a2b21907
--- /dev/null
+++ b/backend/internal/dms/handlers_mainmenu_distro.go
@@ -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
+}
diff --git a/backend/internal/dms/plugins_handlers.go b/backend/internal/dms/plugins_handlers.go
new file mode 100644
index 00000000..debcf946
--- /dev/null
+++ b/backend/internal/dms/plugins_handlers.go
@@ -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}
+ }
+}
diff --git a/backend/internal/dms/plugins_views.go b/backend/internal/dms/plugins_views.go
new file mode 100644
index 00000000..cfcfe0e7
--- /dev/null
+++ b/backend/internal/dms/plugins_views.go
@@ -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")
+}
diff --git a/backend/internal/dms/views_common.go b/backend/internal/dms/views_common.go
new file mode 100644
index 00000000..621de71d
--- /dev/null
+++ b/backend/internal/dms/views_common.go
@@ -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)
+}
diff --git a/backend/internal/dms/views_features.go b/backend/internal/dms/views_features.go
new file mode 100644
index 00000000..eda4da67
--- /dev/null
+++ b/backend/internal/dms/views_features.go
@@ -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
+}
diff --git a/backend/internal/errdefs/errdefs.go b/backend/internal/errdefs/errdefs.go
new file mode 100644
index 00000000..3b5f1598
--- /dev/null
+++ b/backend/internal/errdefs/errdefs.go
@@ -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")
+)
diff --git a/backend/internal/greeter/installer.go b/backend/internal/greeter/installer.go
new file mode 100644
index 00000000..c16431bf
--- /dev/null
+++ b/backend/internal/greeter/installer.go
@@ -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
+}
diff --git a/backend/internal/hyprland/keybinds.go b/backend/internal/hyprland/keybinds.go
new file mode 100644
index 00000000..6e82e44e
--- /dev/null
+++ b/backend/internal/hyprland/keybinds.go
@@ -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
+}
diff --git a/backend/internal/hyprland/keybinds_test.go b/backend/internal/hyprland/keybinds_test.go
new file mode 100644
index 00000000..302e4838
--- /dev/null
+++ b/backend/internal/hyprland/keybinds_test.go
@@ -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))
+ }
+}
diff --git a/backend/internal/keybinds/discovery.go b/backend/internal/keybinds/discovery.go
new file mode 100644
index 00000000..871a6dcb
--- /dev/null
+++ b/backend/internal/keybinds/discovery.go
@@ -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
+}
diff --git a/backend/internal/keybinds/discovery_test.go b/backend/internal/keybinds/discovery_test.go
new file mode 100644
index 00000000..0f2cef6d
--- /dev/null
+++ b/backend/internal/keybinds/discovery_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/backend/internal/keybinds/providers/hyprland.go b/backend/internal/keybinds/providers/hyprland.go
new file mode 100644
index 00000000..0cb68a5f
--- /dev/null
+++ b/backend/internal/keybinds/providers/hyprland.go
@@ -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, "+")
+}
diff --git a/backend/internal/keybinds/providers/hyprland_test.go b/backend/internal/keybinds/providers/hyprland_test.go
new file mode 100644
index 00000000..0ed9bbd5
--- /dev/null
+++ b/backend/internal/keybinds/providers/hyprland_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/backend/internal/keybinds/providers/jsonfile.go b/backend/internal/keybinds/providers/jsonfile.go
new file mode 100644
index 00000000..d204ef92
--- /dev/null
+++ b/backend/internal/keybinds/providers/jsonfile.go
@@ -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
+}
diff --git a/backend/internal/keybinds/providers/jsonfile_test.go b/backend/internal/keybinds/providers/jsonfile_test.go
new file mode 100644
index 00000000..641d0529
--- /dev/null
+++ b/backend/internal/keybinds/providers/jsonfile_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/backend/internal/keybinds/providers/mangowc.go b/backend/internal/keybinds/providers/mangowc.go
new file mode 100644
index 00000000..804f8215
--- /dev/null
+++ b/backend/internal/keybinds/providers/mangowc.go
@@ -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, "+")
+}
diff --git a/backend/internal/keybinds/providers/mangowc_test.go b/backend/internal/keybinds/providers/mangowc_test.go
new file mode 100644
index 00000000..2910add2
--- /dev/null
+++ b/backend/internal/keybinds/providers/mangowc_test.go
@@ -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")
+ }
+}
diff --git a/backend/internal/keybinds/providers/sway.go b/backend/internal/keybinds/providers/sway.go
new file mode 100644
index 00000000..748cfe47
--- /dev/null
+++ b/backend/internal/keybinds/providers/sway.go
@@ -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, "+")
+}
diff --git a/backend/internal/keybinds/providers/sway_test.go b/backend/internal/keybinds/providers/sway_test.go
new file mode 100644
index 00000000..946042b9
--- /dev/null
+++ b/backend/internal/keybinds/providers/sway_test.go
@@ -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")
+ }
+}
diff --git a/backend/internal/keybinds/registry.go b/backend/internal/keybinds/registry.go
new file mode 100644
index 00000000..d7268383
--- /dev/null
+++ b/backend/internal/keybinds/registry.go
@@ -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()
+}
diff --git a/backend/internal/keybinds/registry_test.go b/backend/internal/keybinds/registry_test.go
new file mode 100644
index 00000000..c3ddfee4
--- /dev/null
+++ b/backend/internal/keybinds/registry_test.go
@@ -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")
+ }
+}
diff --git a/backend/internal/keybinds/types.go b/backend/internal/keybinds/types.go
new file mode 100644
index 00000000..d89111fc
--- /dev/null
+++ b/backend/internal/keybinds/types.go
@@ -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)
+}
diff --git a/backend/internal/log/log.go b/backend/internal/log/log.go
new file mode 100644
index 00000000..ba446e0f
--- /dev/null
+++ b/backend/internal/log/log.go
@@ -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 goose’s 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...) }
diff --git a/backend/internal/logger/filelogger.go b/backend/internal/logger/filelogger.go
new file mode 100644
index 00000000..1c428654
--- /dev/null
+++ b/backend/internal/logger/filelogger.go
@@ -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()
+}
diff --git a/backend/internal/mangowc/keybinds.go b/backend/internal/mangowc/keybinds.go
new file mode 100644
index 00000000..e319df72
--- /dev/null
+++ b/backend/internal/mangowc/keybinds.go
@@ -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
+}
diff --git a/backend/internal/mangowc/keybinds_test.go b/backend/internal/mangowc/keybinds_test.go
new file mode 100644
index 00000000..4ced2cd3
--- /dev/null
+++ b/backend/internal/mangowc/keybinds_test.go
@@ -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")
+ }
+}
diff --git a/backend/internal/mocks/brightness/mock_DBusConn.go b/backend/internal/mocks/brightness/mock_DBusConn.go
new file mode 100644
index 00000000..63acf4fa
--- /dev/null
+++ b/backend/internal/mocks/brightness/mock_DBusConn.go
@@ -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
+}
diff --git a/backend/internal/mocks/cups/mock_CUPSClientInterface.go b/backend/internal/mocks/cups/mock_CUPSClientInterface.go
new file mode 100644
index 00000000..15b33250
--- /dev/null
+++ b/backend/internal/mocks/cups/mock_CUPSClientInterface.go
@@ -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
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_AccessPoint.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_AccessPoint.go
new file mode 100644
index 00000000..ae5124da
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_AccessPoint.go
@@ -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
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_ActiveConnection.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_ActiveConnection.go
new file mode 100644
index 00000000..a4e3344e
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_ActiveConnection.go
@@ -0,0 +1,1025 @@
+// 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"
+)
+
+// MockActiveConnection is an autogenerated mock type for the ActiveConnection type
+type MockActiveConnection struct {
+ mock.Mock
+}
+
+type MockActiveConnection_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockActiveConnection) EXPECT() *MockActiveConnection_Expecter {
+ return &MockActiveConnection_Expecter{mock: &_m.Mock}
+}
+
+// GetPath provides a mock function with no fields
+func (_m *MockActiveConnection) 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
+}
+
+// MockActiveConnection_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
+type MockActiveConnection_GetPath_Call struct {
+ *mock.Call
+}
+
+// GetPath is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPath() *MockActiveConnection_GetPath_Call {
+ return &MockActiveConnection_GetPath_Call{Call: _e.mock.On("GetPath")}
+}
+
+func (_c *MockActiveConnection_GetPath_Call) Run(run func()) *MockActiveConnection_GetPath_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockActiveConnection_GetPath_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockActiveConnection_GetPath_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyConnection provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyConnection() (gonetworkmanager.Connection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyConnection")
+ }
+
+ var r0 gonetworkmanager.Connection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.Connection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.Connection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.Connection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnection'
+type MockActiveConnection_GetPropertyConnection_Call struct {
+ *mock.Call
+}
+
+// GetPropertyConnection is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyConnection() *MockActiveConnection_GetPropertyConnection_Call {
+ return &MockActiveConnection_GetPropertyConnection_Call{Call: _e.mock.On("GetPropertyConnection")}
+}
+
+func (_c *MockActiveConnection_GetPropertyConnection_Call) Run(run func()) *MockActiveConnection_GetPropertyConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyConnection_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockActiveConnection_GetPropertyConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyConnection_Call) RunAndReturn(run func() (gonetworkmanager.Connection, error)) *MockActiveConnection_GetPropertyConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDHCP4Config provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyDHCP4Config() (gonetworkmanager.DHCP4Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDHCP4Config")
+ }
+
+ var r0 gonetworkmanager.DHCP4Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP4Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP4Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.DHCP4Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyDHCP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP4Config'
+type MockActiveConnection_GetPropertyDHCP4Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDHCP4Config is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyDHCP4Config() *MockActiveConnection_GetPropertyDHCP4Config_Call {
+ return &MockActiveConnection_GetPropertyDHCP4Config_Call{Call: _e.mock.On("GetPropertyDHCP4Config")}
+}
+
+func (_c *MockActiveConnection_GetPropertyDHCP4Config_Call) Run(run func()) *MockActiveConnection_GetPropertyDHCP4Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDHCP4Config_Call) Return(_a0 gonetworkmanager.DHCP4Config, _a1 error) *MockActiveConnection_GetPropertyDHCP4Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDHCP4Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP4Config, error)) *MockActiveConnection_GetPropertyDHCP4Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDHCP6Config provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyDHCP6Config() (gonetworkmanager.DHCP6Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDHCP6Config")
+ }
+
+ var r0 gonetworkmanager.DHCP6Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP6Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP6Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.DHCP6Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyDHCP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP6Config'
+type MockActiveConnection_GetPropertyDHCP6Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDHCP6Config is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyDHCP6Config() *MockActiveConnection_GetPropertyDHCP6Config_Call {
+ return &MockActiveConnection_GetPropertyDHCP6Config_Call{Call: _e.mock.On("GetPropertyDHCP6Config")}
+}
+
+func (_c *MockActiveConnection_GetPropertyDHCP6Config_Call) Run(run func()) *MockActiveConnection_GetPropertyDHCP6Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDHCP6Config_Call) Return(_a0 gonetworkmanager.DHCP6Config, _a1 error) *MockActiveConnection_GetPropertyDHCP6Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDHCP6Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP6Config, error)) *MockActiveConnection_GetPropertyDHCP6Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDefault provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyDefault() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDefault")
+ }
+
+ 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
+}
+
+// MockActiveConnection_GetPropertyDefault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDefault'
+type MockActiveConnection_GetPropertyDefault_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDefault is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyDefault() *MockActiveConnection_GetPropertyDefault_Call {
+ return &MockActiveConnection_GetPropertyDefault_Call{Call: _e.mock.On("GetPropertyDefault")}
+}
+
+func (_c *MockActiveConnection_GetPropertyDefault_Call) Run(run func()) *MockActiveConnection_GetPropertyDefault_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDefault_Call) Return(_a0 bool, _a1 error) *MockActiveConnection_GetPropertyDefault_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDefault_Call) RunAndReturn(run func() (bool, error)) *MockActiveConnection_GetPropertyDefault_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDefault6 provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyDefault6() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDefault6")
+ }
+
+ 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
+}
+
+// MockActiveConnection_GetPropertyDefault6_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDefault6'
+type MockActiveConnection_GetPropertyDefault6_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDefault6 is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyDefault6() *MockActiveConnection_GetPropertyDefault6_Call {
+ return &MockActiveConnection_GetPropertyDefault6_Call{Call: _e.mock.On("GetPropertyDefault6")}
+}
+
+func (_c *MockActiveConnection_GetPropertyDefault6_Call) Run(run func()) *MockActiveConnection_GetPropertyDefault6_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDefault6_Call) Return(_a0 bool, _a1 error) *MockActiveConnection_GetPropertyDefault6_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDefault6_Call) RunAndReturn(run func() (bool, error)) *MockActiveConnection_GetPropertyDefault6_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDevices provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyDevices() ([]gonetworkmanager.Device, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDevices")
+ }
+
+ var r0 []gonetworkmanager.Device
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Device)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDevices'
+type MockActiveConnection_GetPropertyDevices_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDevices is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyDevices() *MockActiveConnection_GetPropertyDevices_Call {
+ return &MockActiveConnection_GetPropertyDevices_Call{Call: _e.mock.On("GetPropertyDevices")}
+}
+
+func (_c *MockActiveConnection_GetPropertyDevices_Call) Run(run func()) *MockActiveConnection_GetPropertyDevices_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockActiveConnection_GetPropertyDevices_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockActiveConnection_GetPropertyDevices_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyID provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyID() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyID")
+ }
+
+ 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
+}
+
+// MockActiveConnection_GetPropertyID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyID'
+type MockActiveConnection_GetPropertyID_Call struct {
+ *mock.Call
+}
+
+// GetPropertyID is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyID() *MockActiveConnection_GetPropertyID_Call {
+ return &MockActiveConnection_GetPropertyID_Call{Call: _e.mock.On("GetPropertyID")}
+}
+
+func (_c *MockActiveConnection_GetPropertyID_Call) Run(run func()) *MockActiveConnection_GetPropertyID_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyID_Call) Return(_a0 string, _a1 error) *MockActiveConnection_GetPropertyID_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyID_Call) RunAndReturn(run func() (string, error)) *MockActiveConnection_GetPropertyID_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIP4Config provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyIP4Config() (gonetworkmanager.IP4Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIP4Config")
+ }
+
+ var r0 gonetworkmanager.IP4Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP4Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.IP4Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.IP4Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyIP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP4Config'
+type MockActiveConnection_GetPropertyIP4Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIP4Config is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyIP4Config() *MockActiveConnection_GetPropertyIP4Config_Call {
+ return &MockActiveConnection_GetPropertyIP4Config_Call{Call: _e.mock.On("GetPropertyIP4Config")}
+}
+
+func (_c *MockActiveConnection_GetPropertyIP4Config_Call) Run(run func()) *MockActiveConnection_GetPropertyIP4Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyIP4Config_Call) Return(_a0 gonetworkmanager.IP4Config, _a1 error) *MockActiveConnection_GetPropertyIP4Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyIP4Config_Call) RunAndReturn(run func() (gonetworkmanager.IP4Config, error)) *MockActiveConnection_GetPropertyIP4Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIP6Config provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyIP6Config() (gonetworkmanager.IP6Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIP6Config")
+ }
+
+ var r0 gonetworkmanager.IP6Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP6Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.IP6Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.IP6Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyIP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP6Config'
+type MockActiveConnection_GetPropertyIP6Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIP6Config is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyIP6Config() *MockActiveConnection_GetPropertyIP6Config_Call {
+ return &MockActiveConnection_GetPropertyIP6Config_Call{Call: _e.mock.On("GetPropertyIP6Config")}
+}
+
+func (_c *MockActiveConnection_GetPropertyIP6Config_Call) Run(run func()) *MockActiveConnection_GetPropertyIP6Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyIP6Config_Call) Return(_a0 gonetworkmanager.IP6Config, _a1 error) *MockActiveConnection_GetPropertyIP6Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyIP6Config_Call) RunAndReturn(run func() (gonetworkmanager.IP6Config, error)) *MockActiveConnection_GetPropertyIP6Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyMaster provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyMaster() (gonetworkmanager.Device, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyMaster")
+ }
+
+ var r0 gonetworkmanager.Device
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.Device, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.Device); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.Device)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyMaster_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMaster'
+type MockActiveConnection_GetPropertyMaster_Call struct {
+ *mock.Call
+}
+
+// GetPropertyMaster is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyMaster() *MockActiveConnection_GetPropertyMaster_Call {
+ return &MockActiveConnection_GetPropertyMaster_Call{Call: _e.mock.On("GetPropertyMaster")}
+}
+
+func (_c *MockActiveConnection_GetPropertyMaster_Call) Run(run func()) *MockActiveConnection_GetPropertyMaster_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyMaster_Call) Return(_a0 gonetworkmanager.Device, _a1 error) *MockActiveConnection_GetPropertyMaster_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyMaster_Call) RunAndReturn(run func() (gonetworkmanager.Device, error)) *MockActiveConnection_GetPropertyMaster_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertySpecificObject provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertySpecificObject() (gonetworkmanager.AccessPoint, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertySpecificObject")
+ }
+
+ var r0 gonetworkmanager.AccessPoint
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.AccessPoint, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.AccessPoint); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.AccessPoint)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertySpecificObject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySpecificObject'
+type MockActiveConnection_GetPropertySpecificObject_Call struct {
+ *mock.Call
+}
+
+// GetPropertySpecificObject is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertySpecificObject() *MockActiveConnection_GetPropertySpecificObject_Call {
+ return &MockActiveConnection_GetPropertySpecificObject_Call{Call: _e.mock.On("GetPropertySpecificObject")}
+}
+
+func (_c *MockActiveConnection_GetPropertySpecificObject_Call) Run(run func()) *MockActiveConnection_GetPropertySpecificObject_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertySpecificObject_Call) Return(_a0 gonetworkmanager.AccessPoint, _a1 error) *MockActiveConnection_GetPropertySpecificObject_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertySpecificObject_Call) RunAndReturn(run func() (gonetworkmanager.AccessPoint, error)) *MockActiveConnection_GetPropertySpecificObject_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyState provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyState() (gonetworkmanager.NmActiveConnectionState, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyState")
+ }
+
+ var r0 gonetworkmanager.NmActiveConnectionState
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmActiveConnectionState, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmActiveConnectionState); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmActiveConnectionState)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockActiveConnection_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState'
+type MockActiveConnection_GetPropertyState_Call struct {
+ *mock.Call
+}
+
+// GetPropertyState is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyState() *MockActiveConnection_GetPropertyState_Call {
+ return &MockActiveConnection_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")}
+}
+
+func (_c *MockActiveConnection_GetPropertyState_Call) Run(run func()) *MockActiveConnection_GetPropertyState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmActiveConnectionState, _a1 error) *MockActiveConnection_GetPropertyState_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmActiveConnectionState, error)) *MockActiveConnection_GetPropertyState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyStateFlags provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyStateFlags() (uint32, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyStateFlags")
+ }
+
+ 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
+}
+
+// MockActiveConnection_GetPropertyStateFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyStateFlags'
+type MockActiveConnection_GetPropertyStateFlags_Call struct {
+ *mock.Call
+}
+
+// GetPropertyStateFlags is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyStateFlags() *MockActiveConnection_GetPropertyStateFlags_Call {
+ return &MockActiveConnection_GetPropertyStateFlags_Call{Call: _e.mock.On("GetPropertyStateFlags")}
+}
+
+func (_c *MockActiveConnection_GetPropertyStateFlags_Call) Run(run func()) *MockActiveConnection_GetPropertyStateFlags_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyStateFlags_Call) Return(_a0 uint32, _a1 error) *MockActiveConnection_GetPropertyStateFlags_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyStateFlags_Call) RunAndReturn(run func() (uint32, error)) *MockActiveConnection_GetPropertyStateFlags_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyType provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyType() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyType")
+ }
+
+ 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
+}
+
+// MockActiveConnection_GetPropertyType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyType'
+type MockActiveConnection_GetPropertyType_Call struct {
+ *mock.Call
+}
+
+// GetPropertyType is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyType() *MockActiveConnection_GetPropertyType_Call {
+ return &MockActiveConnection_GetPropertyType_Call{Call: _e.mock.On("GetPropertyType")}
+}
+
+func (_c *MockActiveConnection_GetPropertyType_Call) Run(run func()) *MockActiveConnection_GetPropertyType_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyType_Call) Return(_a0 string, _a1 error) *MockActiveConnection_GetPropertyType_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyType_Call) RunAndReturn(run func() (string, error)) *MockActiveConnection_GetPropertyType_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyUUID provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyUUID() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyUUID")
+ }
+
+ 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
+}
+
+// MockActiveConnection_GetPropertyUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUUID'
+type MockActiveConnection_GetPropertyUUID_Call struct {
+ *mock.Call
+}
+
+// GetPropertyUUID is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyUUID() *MockActiveConnection_GetPropertyUUID_Call {
+ return &MockActiveConnection_GetPropertyUUID_Call{Call: _e.mock.On("GetPropertyUUID")}
+}
+
+func (_c *MockActiveConnection_GetPropertyUUID_Call) Run(run func()) *MockActiveConnection_GetPropertyUUID_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyUUID_Call) Return(_a0 string, _a1 error) *MockActiveConnection_GetPropertyUUID_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyUUID_Call) RunAndReturn(run func() (string, error)) *MockActiveConnection_GetPropertyUUID_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyVPN provides a mock function with no fields
+func (_m *MockActiveConnection) GetPropertyVPN() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyVPN")
+ }
+
+ 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
+}
+
+// MockActiveConnection_GetPropertyVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyVPN'
+type MockActiveConnection_GetPropertyVPN_Call struct {
+ *mock.Call
+}
+
+// GetPropertyVPN is a helper method to define mock.On call
+func (_e *MockActiveConnection_Expecter) GetPropertyVPN() *MockActiveConnection_GetPropertyVPN_Call {
+ return &MockActiveConnection_GetPropertyVPN_Call{Call: _e.mock.On("GetPropertyVPN")}
+}
+
+func (_c *MockActiveConnection_GetPropertyVPN_Call) Run(run func()) *MockActiveConnection_GetPropertyVPN_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyVPN_Call) Return(_a0 bool, _a1 error) *MockActiveConnection_GetPropertyVPN_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockActiveConnection_GetPropertyVPN_Call) RunAndReturn(run func() (bool, error)) *MockActiveConnection_GetPropertyVPN_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SubscribeState provides a mock function with given fields: receiver, exit
+func (_m *MockActiveConnection) SubscribeState(receiver chan gonetworkmanager.StateChange, exit chan struct{}) error {
+ ret := _m.Called(receiver, exit)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SubscribeState")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(chan gonetworkmanager.StateChange, chan struct{}) error); ok {
+ r0 = rf(receiver, exit)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockActiveConnection_SubscribeState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribeState'
+type MockActiveConnection_SubscribeState_Call struct {
+ *mock.Call
+}
+
+// SubscribeState is a helper method to define mock.On call
+// - receiver chan gonetworkmanager.StateChange
+// - exit chan struct{}
+func (_e *MockActiveConnection_Expecter) SubscribeState(receiver interface{}, exit interface{}) *MockActiveConnection_SubscribeState_Call {
+ return &MockActiveConnection_SubscribeState_Call{Call: _e.mock.On("SubscribeState", receiver, exit)}
+}
+
+func (_c *MockActiveConnection_SubscribeState_Call) Run(run func(receiver chan gonetworkmanager.StateChange, exit chan struct{})) *MockActiveConnection_SubscribeState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(chan gonetworkmanager.StateChange), args[1].(chan struct{}))
+ })
+ return _c
+}
+
+func (_c *MockActiveConnection_SubscribeState_Call) Return(err error) *MockActiveConnection_SubscribeState_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockActiveConnection_SubscribeState_Call) RunAndReturn(run func(chan gonetworkmanager.StateChange, chan struct{}) error) *MockActiveConnection_SubscribeState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockActiveConnection creates a new instance of MockActiveConnection. 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 NewMockActiveConnection(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockActiveConnection {
+ mock := &MockActiveConnection{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Connection.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Connection.go
new file mode 100644
index 00000000..84f59e1c
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Connection.go
@@ -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
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Device.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Device.go
new file mode 100644
index 00000000..b2fc0b52
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Device.go
@@ -0,0 +1,1638 @@
+// 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"
+)
+
+// MockDevice is an autogenerated mock type for the Device type
+type MockDevice struct {
+ mock.Mock
+}
+
+type MockDevice_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockDevice) EXPECT() *MockDevice_Expecter {
+ return &MockDevice_Expecter{mock: &_m.Mock}
+}
+
+// Delete provides a mock function with no fields
+func (_m *MockDevice) 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
+}
+
+// MockDevice_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
+type MockDevice_Delete_Call struct {
+ *mock.Call
+}
+
+// Delete is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) Delete() *MockDevice_Delete_Call {
+ return &MockDevice_Delete_Call{Call: _e.mock.On("Delete")}
+}
+
+func (_c *MockDevice_Delete_Call) Run(run func()) *MockDevice_Delete_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_Delete_Call) Return(_a0 error) *MockDevice_Delete_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDevice_Delete_Call) RunAndReturn(run func() error) *MockDevice_Delete_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Disconnect provides a mock function with no fields
+func (_m *MockDevice) Disconnect() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Disconnect")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDevice_Disconnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Disconnect'
+type MockDevice_Disconnect_Call struct {
+ *mock.Call
+}
+
+// Disconnect is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) Disconnect() *MockDevice_Disconnect_Call {
+ return &MockDevice_Disconnect_Call{Call: _e.mock.On("Disconnect")}
+}
+
+func (_c *MockDevice_Disconnect_Call) Run(run func()) *MockDevice_Disconnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_Disconnect_Call) Return(_a0 error) *MockDevice_Disconnect_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDevice_Disconnect_Call) RunAndReturn(run func() error) *MockDevice_Disconnect_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPath provides a mock function with no fields
+func (_m *MockDevice) 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
+}
+
+// MockDevice_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
+type MockDevice_GetPath_Call struct {
+ *mock.Call
+}
+
+// GetPath is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPath() *MockDevice_GetPath_Call {
+ return &MockDevice_GetPath_Call{Call: _e.mock.On("GetPath")}
+}
+
+func (_c *MockDevice_GetPath_Call) Run(run func()) *MockDevice_GetPath_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockDevice_GetPath_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDevice_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockDevice_GetPath_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyActiveConnection provides a mock function with no fields
+func (_m *MockDevice) GetPropertyActiveConnection() (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyActiveConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyActiveConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveConnection'
+type MockDevice_GetPropertyActiveConnection_Call struct {
+ *mock.Call
+}
+
+// GetPropertyActiveConnection is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyActiveConnection() *MockDevice_GetPropertyActiveConnection_Call {
+ return &MockDevice_GetPropertyActiveConnection_Call{Call: _e.mock.On("GetPropertyActiveConnection")}
+}
+
+func (_c *MockDevice_GetPropertyActiveConnection_Call) Run(run func()) *MockDevice_GetPropertyActiveConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyActiveConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockDevice_GetPropertyActiveConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyActiveConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockDevice_GetPropertyActiveConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyAutoConnect provides a mock function with no fields
+func (_m *MockDevice) GetPropertyAutoConnect() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyAutoConnect")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAutoConnect'
+type MockDevice_GetPropertyAutoConnect_Call struct {
+ *mock.Call
+}
+
+// GetPropertyAutoConnect is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyAutoConnect() *MockDevice_GetPropertyAutoConnect_Call {
+ return &MockDevice_GetPropertyAutoConnect_Call{Call: _e.mock.On("GetPropertyAutoConnect")}
+}
+
+func (_c *MockDevice_GetPropertyAutoConnect_Call) Run(run func()) *MockDevice_GetPropertyAutoConnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyAutoConnect_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyAutoConnect_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyAutoConnect_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyAutoConnect_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyAvailableConnections provides a mock function with no fields
+func (_m *MockDevice) GetPropertyAvailableConnections() ([]gonetworkmanager.Connection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyAvailableConnections")
+ }
+
+ var r0 []gonetworkmanager.Connection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Connection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Connection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Connection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyAvailableConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAvailableConnections'
+type MockDevice_GetPropertyAvailableConnections_Call struct {
+ *mock.Call
+}
+
+// GetPropertyAvailableConnections is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyAvailableConnections() *MockDevice_GetPropertyAvailableConnections_Call {
+ return &MockDevice_GetPropertyAvailableConnections_Call{Call: _e.mock.On("GetPropertyAvailableConnections")}
+}
+
+func (_c *MockDevice_GetPropertyAvailableConnections_Call) Run(run func()) *MockDevice_GetPropertyAvailableConnections_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyAvailableConnections_Call) Return(_a0 []gonetworkmanager.Connection, _a1 error) *MockDevice_GetPropertyAvailableConnections_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyAvailableConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.Connection, error)) *MockDevice_GetPropertyAvailableConnections_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDHCP4Config provides a mock function with no fields
+func (_m *MockDevice) GetPropertyDHCP4Config() (gonetworkmanager.DHCP4Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDHCP4Config")
+ }
+
+ var r0 gonetworkmanager.DHCP4Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP4Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP4Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.DHCP4Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyDHCP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP4Config'
+type MockDevice_GetPropertyDHCP4Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDHCP4Config is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyDHCP4Config() *MockDevice_GetPropertyDHCP4Config_Call {
+ return &MockDevice_GetPropertyDHCP4Config_Call{Call: _e.mock.On("GetPropertyDHCP4Config")}
+}
+
+func (_c *MockDevice_GetPropertyDHCP4Config_Call) Run(run func()) *MockDevice_GetPropertyDHCP4Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDHCP4Config_Call) Return(_a0 gonetworkmanager.DHCP4Config, _a1 error) *MockDevice_GetPropertyDHCP4Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDHCP4Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP4Config, error)) *MockDevice_GetPropertyDHCP4Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDHCP6Config provides a mock function with no fields
+func (_m *MockDevice) GetPropertyDHCP6Config() (gonetworkmanager.DHCP6Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDHCP6Config")
+ }
+
+ var r0 gonetworkmanager.DHCP6Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP6Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP6Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.DHCP6Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyDHCP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP6Config'
+type MockDevice_GetPropertyDHCP6Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDHCP6Config is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyDHCP6Config() *MockDevice_GetPropertyDHCP6Config_Call {
+ return &MockDevice_GetPropertyDHCP6Config_Call{Call: _e.mock.On("GetPropertyDHCP6Config")}
+}
+
+func (_c *MockDevice_GetPropertyDHCP6Config_Call) Run(run func()) *MockDevice_GetPropertyDHCP6Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDHCP6Config_Call) Return(_a0 gonetworkmanager.DHCP6Config, _a1 error) *MockDevice_GetPropertyDHCP6Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDHCP6Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP6Config, error)) *MockDevice_GetPropertyDHCP6Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDeviceType provides a mock function with no fields
+func (_m *MockDevice) GetPropertyDeviceType() (gonetworkmanager.NmDeviceType, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDeviceType")
+ }
+
+ var r0 gonetworkmanager.NmDeviceType
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceType, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceType); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmDeviceType)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyDeviceType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDeviceType'
+type MockDevice_GetPropertyDeviceType_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDeviceType is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyDeviceType() *MockDevice_GetPropertyDeviceType_Call {
+ return &MockDevice_GetPropertyDeviceType_Call{Call: _e.mock.On("GetPropertyDeviceType")}
+}
+
+func (_c *MockDevice_GetPropertyDeviceType_Call) Run(run func()) *MockDevice_GetPropertyDeviceType_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDeviceType_Call) Return(_a0 gonetworkmanager.NmDeviceType, _a1 error) *MockDevice_GetPropertyDeviceType_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDeviceType_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceType, error)) *MockDevice_GetPropertyDeviceType_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDriver provides a mock function with no fields
+func (_m *MockDevice) GetPropertyDriver() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDriver")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyDriver_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriver'
+type MockDevice_GetPropertyDriver_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDriver is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyDriver() *MockDevice_GetPropertyDriver_Call {
+ return &MockDevice_GetPropertyDriver_Call{Call: _e.mock.On("GetPropertyDriver")}
+}
+
+func (_c *MockDevice_GetPropertyDriver_Call) Run(run func()) *MockDevice_GetPropertyDriver_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDriver_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyDriver_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDriver_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyDriver_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDriverVersion provides a mock function with no fields
+func (_m *MockDevice) GetPropertyDriverVersion() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDriverVersion")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyDriverVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriverVersion'
+type MockDevice_GetPropertyDriverVersion_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDriverVersion is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyDriverVersion() *MockDevice_GetPropertyDriverVersion_Call {
+ return &MockDevice_GetPropertyDriverVersion_Call{Call: _e.mock.On("GetPropertyDriverVersion")}
+}
+
+func (_c *MockDevice_GetPropertyDriverVersion_Call) Run(run func()) *MockDevice_GetPropertyDriverVersion_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDriverVersion_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyDriverVersion_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyDriverVersion_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyDriverVersion_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyFirmwareMissing provides a mock function with no fields
+func (_m *MockDevice) GetPropertyFirmwareMissing() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyFirmwareMissing")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyFirmwareMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareMissing'
+type MockDevice_GetPropertyFirmwareMissing_Call struct {
+ *mock.Call
+}
+
+// GetPropertyFirmwareMissing is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyFirmwareMissing() *MockDevice_GetPropertyFirmwareMissing_Call {
+ return &MockDevice_GetPropertyFirmwareMissing_Call{Call: _e.mock.On("GetPropertyFirmwareMissing")}
+}
+
+func (_c *MockDevice_GetPropertyFirmwareMissing_Call) Run(run func()) *MockDevice_GetPropertyFirmwareMissing_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyFirmwareMissing_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyFirmwareMissing_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyFirmwareMissing_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyFirmwareMissing_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyFirmwareVersion provides a mock function with no fields
+func (_m *MockDevice) GetPropertyFirmwareVersion() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyFirmwareVersion")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyFirmwareVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareVersion'
+type MockDevice_GetPropertyFirmwareVersion_Call struct {
+ *mock.Call
+}
+
+// GetPropertyFirmwareVersion is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyFirmwareVersion() *MockDevice_GetPropertyFirmwareVersion_Call {
+ return &MockDevice_GetPropertyFirmwareVersion_Call{Call: _e.mock.On("GetPropertyFirmwareVersion")}
+}
+
+func (_c *MockDevice_GetPropertyFirmwareVersion_Call) Run(run func()) *MockDevice_GetPropertyFirmwareVersion_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyFirmwareVersion_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyFirmwareVersion_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyFirmwareVersion_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyFirmwareVersion_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIP4Config provides a mock function with no fields
+func (_m *MockDevice) GetPropertyIP4Config() (gonetworkmanager.IP4Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIP4Config")
+ }
+
+ var r0 gonetworkmanager.IP4Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP4Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.IP4Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.IP4Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyIP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP4Config'
+type MockDevice_GetPropertyIP4Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIP4Config is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyIP4Config() *MockDevice_GetPropertyIP4Config_Call {
+ return &MockDevice_GetPropertyIP4Config_Call{Call: _e.mock.On("GetPropertyIP4Config")}
+}
+
+func (_c *MockDevice_GetPropertyIP4Config_Call) Run(run func()) *MockDevice_GetPropertyIP4Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIP4Config_Call) Return(_a0 gonetworkmanager.IP4Config, _a1 error) *MockDevice_GetPropertyIP4Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIP4Config_Call) RunAndReturn(run func() (gonetworkmanager.IP4Config, error)) *MockDevice_GetPropertyIP4Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIP6Config provides a mock function with no fields
+func (_m *MockDevice) GetPropertyIP6Config() (gonetworkmanager.IP6Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIP6Config")
+ }
+
+ var r0 gonetworkmanager.IP6Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP6Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.IP6Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.IP6Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyIP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP6Config'
+type MockDevice_GetPropertyIP6Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIP6Config is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyIP6Config() *MockDevice_GetPropertyIP6Config_Call {
+ return &MockDevice_GetPropertyIP6Config_Call{Call: _e.mock.On("GetPropertyIP6Config")}
+}
+
+func (_c *MockDevice_GetPropertyIP6Config_Call) Run(run func()) *MockDevice_GetPropertyIP6Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIP6Config_Call) Return(_a0 gonetworkmanager.IP6Config, _a1 error) *MockDevice_GetPropertyIP6Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIP6Config_Call) RunAndReturn(run func() (gonetworkmanager.IP6Config, error)) *MockDevice_GetPropertyIP6Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyInterface provides a mock function with no fields
+func (_m *MockDevice) GetPropertyInterface() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyInterface")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyInterface'
+type MockDevice_GetPropertyInterface_Call struct {
+ *mock.Call
+}
+
+// GetPropertyInterface is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyInterface() *MockDevice_GetPropertyInterface_Call {
+ return &MockDevice_GetPropertyInterface_Call{Call: _e.mock.On("GetPropertyInterface")}
+}
+
+func (_c *MockDevice_GetPropertyInterface_Call) Run(run func()) *MockDevice_GetPropertyInterface_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyInterface_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyInterface_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyInterface_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyInterface_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIp4Connectivity provides a mock function with no fields
+func (_m *MockDevice) GetPropertyIp4Connectivity() (gonetworkmanager.NmConnectivity, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIp4Connectivity")
+ }
+
+ var r0 gonetworkmanager.NmConnectivity
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmConnectivity, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmConnectivity); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmConnectivity)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyIp4Connectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIp4Connectivity'
+type MockDevice_GetPropertyIp4Connectivity_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIp4Connectivity is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyIp4Connectivity() *MockDevice_GetPropertyIp4Connectivity_Call {
+ return &MockDevice_GetPropertyIp4Connectivity_Call{Call: _e.mock.On("GetPropertyIp4Connectivity")}
+}
+
+func (_c *MockDevice_GetPropertyIp4Connectivity_Call) Run(run func()) *MockDevice_GetPropertyIp4Connectivity_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIp4Connectivity_Call) Return(_a0 gonetworkmanager.NmConnectivity, _a1 error) *MockDevice_GetPropertyIp4Connectivity_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIp4Connectivity_Call) RunAndReturn(run func() (gonetworkmanager.NmConnectivity, error)) *MockDevice_GetPropertyIp4Connectivity_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIpInterface provides a mock function with no fields
+func (_m *MockDevice) GetPropertyIpInterface() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIpInterface")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyIpInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIpInterface'
+type MockDevice_GetPropertyIpInterface_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIpInterface is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyIpInterface() *MockDevice_GetPropertyIpInterface_Call {
+ return &MockDevice_GetPropertyIpInterface_Call{Call: _e.mock.On("GetPropertyIpInterface")}
+}
+
+func (_c *MockDevice_GetPropertyIpInterface_Call) Run(run func()) *MockDevice_GetPropertyIpInterface_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIpInterface_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyIpInterface_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyIpInterface_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyIpInterface_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyManaged provides a mock function with no fields
+func (_m *MockDevice) GetPropertyManaged() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyManaged")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyManaged'
+type MockDevice_GetPropertyManaged_Call struct {
+ *mock.Call
+}
+
+// GetPropertyManaged is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyManaged() *MockDevice_GetPropertyManaged_Call {
+ return &MockDevice_GetPropertyManaged_Call{Call: _e.mock.On("GetPropertyManaged")}
+}
+
+func (_c *MockDevice_GetPropertyManaged_Call) Run(run func()) *MockDevice_GetPropertyManaged_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyManaged_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyManaged_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyManaged_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyManaged_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyMtu provides a mock function with no fields
+func (_m *MockDevice) GetPropertyMtu() (uint32, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyMtu")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyMtu_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMtu'
+type MockDevice_GetPropertyMtu_Call struct {
+ *mock.Call
+}
+
+// GetPropertyMtu is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyMtu() *MockDevice_GetPropertyMtu_Call {
+ return &MockDevice_GetPropertyMtu_Call{Call: _e.mock.On("GetPropertyMtu")}
+}
+
+func (_c *MockDevice_GetPropertyMtu_Call) Run(run func()) *MockDevice_GetPropertyMtu_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyMtu_Call) Return(_a0 uint32, _a1 error) *MockDevice_GetPropertyMtu_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyMtu_Call) RunAndReturn(run func() (uint32, error)) *MockDevice_GetPropertyMtu_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyNmPluginMissing provides a mock function with no fields
+func (_m *MockDevice) GetPropertyNmPluginMissing() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyNmPluginMissing")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyNmPluginMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNmPluginMissing'
+type MockDevice_GetPropertyNmPluginMissing_Call struct {
+ *mock.Call
+}
+
+// GetPropertyNmPluginMissing is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyNmPluginMissing() *MockDevice_GetPropertyNmPluginMissing_Call {
+ return &MockDevice_GetPropertyNmPluginMissing_Call{Call: _e.mock.On("GetPropertyNmPluginMissing")}
+}
+
+func (_c *MockDevice_GetPropertyNmPluginMissing_Call) Run(run func()) *MockDevice_GetPropertyNmPluginMissing_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyNmPluginMissing_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyNmPluginMissing_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyNmPluginMissing_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyNmPluginMissing_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyPhysicalPortId provides a mock function with no fields
+func (_m *MockDevice) GetPropertyPhysicalPortId() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyPhysicalPortId")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyPhysicalPortId_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPhysicalPortId'
+type MockDevice_GetPropertyPhysicalPortId_Call struct {
+ *mock.Call
+}
+
+// GetPropertyPhysicalPortId is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyPhysicalPortId() *MockDevice_GetPropertyPhysicalPortId_Call {
+ return &MockDevice_GetPropertyPhysicalPortId_Call{Call: _e.mock.On("GetPropertyPhysicalPortId")}
+}
+
+func (_c *MockDevice_GetPropertyPhysicalPortId_Call) Run(run func()) *MockDevice_GetPropertyPhysicalPortId_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyPhysicalPortId_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyPhysicalPortId_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyPhysicalPortId_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyPhysicalPortId_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyReal provides a mock function with no fields
+func (_m *MockDevice) GetPropertyReal() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyReal")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyReal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyReal'
+type MockDevice_GetPropertyReal_Call struct {
+ *mock.Call
+}
+
+// GetPropertyReal is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyReal() *MockDevice_GetPropertyReal_Call {
+ return &MockDevice_GetPropertyReal_Call{Call: _e.mock.On("GetPropertyReal")}
+}
+
+func (_c *MockDevice_GetPropertyReal_Call) Run(run func()) *MockDevice_GetPropertyReal_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyReal_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyReal_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyReal_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyReal_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyState provides a mock function with no fields
+func (_m *MockDevice) GetPropertyState() (gonetworkmanager.NmDeviceState, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyState")
+ }
+
+ var r0 gonetworkmanager.NmDeviceState
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceState, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceState); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmDeviceState)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDevice_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState'
+type MockDevice_GetPropertyState_Call struct {
+ *mock.Call
+}
+
+// GetPropertyState is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyState() *MockDevice_GetPropertyState_Call {
+ return &MockDevice_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")}
+}
+
+func (_c *MockDevice_GetPropertyState_Call) Run(run func()) *MockDevice_GetPropertyState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmDeviceState, _a1 error) *MockDevice_GetPropertyState_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceState, error)) *MockDevice_GetPropertyState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyUdi provides a mock function with no fields
+func (_m *MockDevice) GetPropertyUdi() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyUdi")
+ }
+
+ 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
+}
+
+// MockDevice_GetPropertyUdi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUdi'
+type MockDevice_GetPropertyUdi_Call struct {
+ *mock.Call
+}
+
+// GetPropertyUdi is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) GetPropertyUdi() *MockDevice_GetPropertyUdi_Call {
+ return &MockDevice_GetPropertyUdi_Call{Call: _e.mock.On("GetPropertyUdi")}
+}
+
+func (_c *MockDevice_GetPropertyUdi_Call) Run(run func()) *MockDevice_GetPropertyUdi_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyUdi_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyUdi_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_GetPropertyUdi_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyUdi_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// MarshalJSON provides a mock function with no fields
+func (_m *MockDevice) 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
+}
+
+// MockDevice_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
+type MockDevice_MarshalJSON_Call struct {
+ *mock.Call
+}
+
+// MarshalJSON is a helper method to define mock.On call
+func (_e *MockDevice_Expecter) MarshalJSON() *MockDevice_MarshalJSON_Call {
+ return &MockDevice_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
+}
+
+func (_c *MockDevice_MarshalJSON_Call) Run(run func()) *MockDevice_MarshalJSON_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDevice_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockDevice_MarshalJSON_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDevice_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockDevice_MarshalJSON_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Reapply provides a mock function with given fields: connection, versionId, flags
+func (_m *MockDevice) Reapply(connection gonetworkmanager.Connection, versionId uint64, flags uint32) error {
+ ret := _m.Called(connection, versionId, flags)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Reapply")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, uint64, uint32) error); ok {
+ r0 = rf(connection, versionId, flags)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDevice_Reapply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reapply'
+type MockDevice_Reapply_Call struct {
+ *mock.Call
+}
+
+// Reapply is a helper method to define mock.On call
+// - connection gonetworkmanager.Connection
+// - versionId uint64
+// - flags uint32
+func (_e *MockDevice_Expecter) Reapply(connection interface{}, versionId interface{}, flags interface{}) *MockDevice_Reapply_Call {
+ return &MockDevice_Reapply_Call{Call: _e.mock.On("Reapply", connection, versionId, flags)}
+}
+
+func (_c *MockDevice_Reapply_Call) Run(run func(connection gonetworkmanager.Connection, versionId uint64, flags uint32)) *MockDevice_Reapply_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.Connection), args[1].(uint64), args[2].(uint32))
+ })
+ return _c
+}
+
+func (_c *MockDevice_Reapply_Call) Return(_a0 error) *MockDevice_Reapply_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDevice_Reapply_Call) RunAndReturn(run func(gonetworkmanager.Connection, uint64, uint32) error) *MockDevice_Reapply_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetPropertyAutoConnect provides a mock function with given fields: _a0
+func (_m *MockDevice) SetPropertyAutoConnect(_a0 bool) error {
+ ret := _m.Called(_a0)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetPropertyAutoConnect")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(_a0)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDevice_SetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyAutoConnect'
+type MockDevice_SetPropertyAutoConnect_Call struct {
+ *mock.Call
+}
+
+// SetPropertyAutoConnect is a helper method to define mock.On call
+// - _a0 bool
+func (_e *MockDevice_Expecter) SetPropertyAutoConnect(_a0 interface{}) *MockDevice_SetPropertyAutoConnect_Call {
+ return &MockDevice_SetPropertyAutoConnect_Call{Call: _e.mock.On("SetPropertyAutoConnect", _a0)}
+}
+
+func (_c *MockDevice_SetPropertyAutoConnect_Call) Run(run func(_a0 bool)) *MockDevice_SetPropertyAutoConnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockDevice_SetPropertyAutoConnect_Call) Return(_a0 error) *MockDevice_SetPropertyAutoConnect_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDevice_SetPropertyAutoConnect_Call) RunAndReturn(run func(bool) error) *MockDevice_SetPropertyAutoConnect_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetPropertyManaged provides a mock function with given fields: _a0
+func (_m *MockDevice) SetPropertyManaged(_a0 bool) error {
+ ret := _m.Called(_a0)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetPropertyManaged")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(_a0)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDevice_SetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyManaged'
+type MockDevice_SetPropertyManaged_Call struct {
+ *mock.Call
+}
+
+// SetPropertyManaged is a helper method to define mock.On call
+// - _a0 bool
+func (_e *MockDevice_Expecter) SetPropertyManaged(_a0 interface{}) *MockDevice_SetPropertyManaged_Call {
+ return &MockDevice_SetPropertyManaged_Call{Call: _e.mock.On("SetPropertyManaged", _a0)}
+}
+
+func (_c *MockDevice_SetPropertyManaged_Call) Run(run func(_a0 bool)) *MockDevice_SetPropertyManaged_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockDevice_SetPropertyManaged_Call) Return(_a0 error) *MockDevice_SetPropertyManaged_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDevice_SetPropertyManaged_Call) RunAndReturn(run func(bool) error) *MockDevice_SetPropertyManaged_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SubscribeState provides a mock function with given fields: receiver, exit
+func (_m *MockDevice) SubscribeState(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{}) error {
+ ret := _m.Called(receiver, exit)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SubscribeState")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error); ok {
+ r0 = rf(receiver, exit)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDevice_SubscribeState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribeState'
+type MockDevice_SubscribeState_Call struct {
+ *mock.Call
+}
+
+// SubscribeState is a helper method to define mock.On call
+// - receiver chan gonetworkmanager.DeviceStateChange
+// - exit chan struct{}
+func (_e *MockDevice_Expecter) SubscribeState(receiver interface{}, exit interface{}) *MockDevice_SubscribeState_Call {
+ return &MockDevice_SubscribeState_Call{Call: _e.mock.On("SubscribeState", receiver, exit)}
+}
+
+func (_c *MockDevice_SubscribeState_Call) Run(run func(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{})) *MockDevice_SubscribeState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(chan gonetworkmanager.DeviceStateChange), args[1].(chan struct{}))
+ })
+ return _c
+}
+
+func (_c *MockDevice_SubscribeState_Call) Return(err error) *MockDevice_SubscribeState_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockDevice_SubscribeState_Call) RunAndReturn(run func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error) *MockDevice_SubscribeState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockDevice creates a new instance of MockDevice. 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 NewMockDevice(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockDevice {
+ mock := &MockDevice{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_DeviceWireless.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_DeviceWireless.go
new file mode 100644
index 00000000..d2d3f2d6
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_DeviceWireless.go
@@ -0,0 +1,2241 @@
+// 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"
+)
+
+// MockDeviceWireless is an autogenerated mock type for the DeviceWireless type
+type MockDeviceWireless struct {
+ mock.Mock
+}
+
+type MockDeviceWireless_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockDeviceWireless) EXPECT() *MockDeviceWireless_Expecter {
+ return &MockDeviceWireless_Expecter{mock: &_m.Mock}
+}
+
+// Delete provides a mock function with no fields
+func (_m *MockDeviceWireless) 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
+}
+
+// MockDeviceWireless_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
+type MockDeviceWireless_Delete_Call struct {
+ *mock.Call
+}
+
+// Delete is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) Delete() *MockDeviceWireless_Delete_Call {
+ return &MockDeviceWireless_Delete_Call{Call: _e.mock.On("Delete")}
+}
+
+func (_c *MockDeviceWireless_Delete_Call) Run(run func()) *MockDeviceWireless_Delete_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_Delete_Call) Return(_a0 error) *MockDeviceWireless_Delete_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDeviceWireless_Delete_Call) RunAndReturn(run func() error) *MockDeviceWireless_Delete_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Disconnect provides a mock function with no fields
+func (_m *MockDeviceWireless) Disconnect() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Disconnect")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDeviceWireless_Disconnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Disconnect'
+type MockDeviceWireless_Disconnect_Call struct {
+ *mock.Call
+}
+
+// Disconnect is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) Disconnect() *MockDeviceWireless_Disconnect_Call {
+ return &MockDeviceWireless_Disconnect_Call{Call: _e.mock.On("Disconnect")}
+}
+
+func (_c *MockDeviceWireless_Disconnect_Call) Run(run func()) *MockDeviceWireless_Disconnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_Disconnect_Call) Return(_a0 error) *MockDeviceWireless_Disconnect_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDeviceWireless_Disconnect_Call) RunAndReturn(run func() error) *MockDeviceWireless_Disconnect_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetAccessPoints provides a mock function with no fields
+func (_m *MockDeviceWireless) GetAccessPoints() ([]gonetworkmanager.AccessPoint, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetAccessPoints")
+ }
+
+ var r0 []gonetworkmanager.AccessPoint
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.AccessPoint, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.AccessPoint); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.AccessPoint)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetAccessPoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessPoints'
+type MockDeviceWireless_GetAccessPoints_Call struct {
+ *mock.Call
+}
+
+// GetAccessPoints is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetAccessPoints() *MockDeviceWireless_GetAccessPoints_Call {
+ return &MockDeviceWireless_GetAccessPoints_Call{Call: _e.mock.On("GetAccessPoints")}
+}
+
+func (_c *MockDeviceWireless_GetAccessPoints_Call) Run(run func()) *MockDeviceWireless_GetAccessPoints_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetAccessPoints_Call) Return(_a0 []gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetAccessPoints_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetAccessPoints_Call) RunAndReturn(run func() ([]gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetAccessPoints_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetAllAccessPoints provides a mock function with no fields
+func (_m *MockDeviceWireless) GetAllAccessPoints() ([]gonetworkmanager.AccessPoint, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetAllAccessPoints")
+ }
+
+ var r0 []gonetworkmanager.AccessPoint
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.AccessPoint, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.AccessPoint); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.AccessPoint)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetAllAccessPoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAccessPoints'
+type MockDeviceWireless_GetAllAccessPoints_Call struct {
+ *mock.Call
+}
+
+// GetAllAccessPoints is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetAllAccessPoints() *MockDeviceWireless_GetAllAccessPoints_Call {
+ return &MockDeviceWireless_GetAllAccessPoints_Call{Call: _e.mock.On("GetAllAccessPoints")}
+}
+
+func (_c *MockDeviceWireless_GetAllAccessPoints_Call) Run(run func()) *MockDeviceWireless_GetAllAccessPoints_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetAllAccessPoints_Call) Return(_a0 []gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetAllAccessPoints_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetAllAccessPoints_Call) RunAndReturn(run func() ([]gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetAllAccessPoints_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPath provides a mock function with no fields
+func (_m *MockDeviceWireless) 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
+}
+
+// MockDeviceWireless_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
+type MockDeviceWireless_GetPath_Call struct {
+ *mock.Call
+}
+
+// GetPath is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPath() *MockDeviceWireless_GetPath_Call {
+ return &MockDeviceWireless_GetPath_Call{Call: _e.mock.On("GetPath")}
+}
+
+func (_c *MockDeviceWireless_GetPath_Call) Run(run func()) *MockDeviceWireless_GetPath_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockDeviceWireless_GetPath_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockDeviceWireless_GetPath_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyAccessPoints provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyAccessPoints() ([]gonetworkmanager.AccessPoint, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyAccessPoints")
+ }
+
+ var r0 []gonetworkmanager.AccessPoint
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.AccessPoint, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.AccessPoint); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.AccessPoint)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyAccessPoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAccessPoints'
+type MockDeviceWireless_GetPropertyAccessPoints_Call struct {
+ *mock.Call
+}
+
+// GetPropertyAccessPoints is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyAccessPoints() *MockDeviceWireless_GetPropertyAccessPoints_Call {
+ return &MockDeviceWireless_GetPropertyAccessPoints_Call{Call: _e.mock.On("GetPropertyAccessPoints")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyAccessPoints_Call) Run(run func()) *MockDeviceWireless_GetPropertyAccessPoints_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyAccessPoints_Call) Return(_a0 []gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetPropertyAccessPoints_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyAccessPoints_Call) RunAndReturn(run func() ([]gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetPropertyAccessPoints_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyActiveAccessPoint provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyActiveAccessPoint() (gonetworkmanager.AccessPoint, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyActiveAccessPoint")
+ }
+
+ var r0 gonetworkmanager.AccessPoint
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.AccessPoint, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.AccessPoint); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.AccessPoint)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyActiveAccessPoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveAccessPoint'
+type MockDeviceWireless_GetPropertyActiveAccessPoint_Call struct {
+ *mock.Call
+}
+
+// GetPropertyActiveAccessPoint is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyActiveAccessPoint() *MockDeviceWireless_GetPropertyActiveAccessPoint_Call {
+ return &MockDeviceWireless_GetPropertyActiveAccessPoint_Call{Call: _e.mock.On("GetPropertyActiveAccessPoint")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyActiveAccessPoint_Call) Run(run func()) *MockDeviceWireless_GetPropertyActiveAccessPoint_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyActiveAccessPoint_Call) Return(_a0 gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetPropertyActiveAccessPoint_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyActiveAccessPoint_Call) RunAndReturn(run func() (gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetPropertyActiveAccessPoint_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyActiveConnection provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyActiveConnection() (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyActiveConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyActiveConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveConnection'
+type MockDeviceWireless_GetPropertyActiveConnection_Call struct {
+ *mock.Call
+}
+
+// GetPropertyActiveConnection is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyActiveConnection() *MockDeviceWireless_GetPropertyActiveConnection_Call {
+ return &MockDeviceWireless_GetPropertyActiveConnection_Call{Call: _e.mock.On("GetPropertyActiveConnection")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyActiveConnection_Call) Run(run func()) *MockDeviceWireless_GetPropertyActiveConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyActiveConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockDeviceWireless_GetPropertyActiveConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyActiveConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockDeviceWireless_GetPropertyActiveConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyAutoConnect provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyAutoConnect() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyAutoConnect")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAutoConnect'
+type MockDeviceWireless_GetPropertyAutoConnect_Call struct {
+ *mock.Call
+}
+
+// GetPropertyAutoConnect is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyAutoConnect() *MockDeviceWireless_GetPropertyAutoConnect_Call {
+ return &MockDeviceWireless_GetPropertyAutoConnect_Call{Call: _e.mock.On("GetPropertyAutoConnect")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyAutoConnect_Call) Run(run func()) *MockDeviceWireless_GetPropertyAutoConnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyAutoConnect_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyAutoConnect_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyAutoConnect_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyAutoConnect_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyAvailableConnections provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyAvailableConnections() ([]gonetworkmanager.Connection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyAvailableConnections")
+ }
+
+ var r0 []gonetworkmanager.Connection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Connection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Connection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Connection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyAvailableConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAvailableConnections'
+type MockDeviceWireless_GetPropertyAvailableConnections_Call struct {
+ *mock.Call
+}
+
+// GetPropertyAvailableConnections is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyAvailableConnections() *MockDeviceWireless_GetPropertyAvailableConnections_Call {
+ return &MockDeviceWireless_GetPropertyAvailableConnections_Call{Call: _e.mock.On("GetPropertyAvailableConnections")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyAvailableConnections_Call) Run(run func()) *MockDeviceWireless_GetPropertyAvailableConnections_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyAvailableConnections_Call) Return(_a0 []gonetworkmanager.Connection, _a1 error) *MockDeviceWireless_GetPropertyAvailableConnections_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyAvailableConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.Connection, error)) *MockDeviceWireless_GetPropertyAvailableConnections_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyBitrate provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyBitrate() (uint32, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyBitrate")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyBitrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyBitrate'
+type MockDeviceWireless_GetPropertyBitrate_Call struct {
+ *mock.Call
+}
+
+// GetPropertyBitrate is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyBitrate() *MockDeviceWireless_GetPropertyBitrate_Call {
+ return &MockDeviceWireless_GetPropertyBitrate_Call{Call: _e.mock.On("GetPropertyBitrate")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyBitrate_Call) Run(run func()) *MockDeviceWireless_GetPropertyBitrate_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyBitrate_Call) Return(_a0 uint32, _a1 error) *MockDeviceWireless_GetPropertyBitrate_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyBitrate_Call) RunAndReturn(run func() (uint32, error)) *MockDeviceWireless_GetPropertyBitrate_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDHCP4Config provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyDHCP4Config() (gonetworkmanager.DHCP4Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDHCP4Config")
+ }
+
+ var r0 gonetworkmanager.DHCP4Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP4Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP4Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.DHCP4Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyDHCP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP4Config'
+type MockDeviceWireless_GetPropertyDHCP4Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDHCP4Config is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyDHCP4Config() *MockDeviceWireless_GetPropertyDHCP4Config_Call {
+ return &MockDeviceWireless_GetPropertyDHCP4Config_Call{Call: _e.mock.On("GetPropertyDHCP4Config")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyDHCP4Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyDHCP4Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDHCP4Config_Call) Return(_a0 gonetworkmanager.DHCP4Config, _a1 error) *MockDeviceWireless_GetPropertyDHCP4Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDHCP4Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP4Config, error)) *MockDeviceWireless_GetPropertyDHCP4Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDHCP6Config provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyDHCP6Config() (gonetworkmanager.DHCP6Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDHCP6Config")
+ }
+
+ var r0 gonetworkmanager.DHCP6Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP6Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP6Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.DHCP6Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyDHCP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP6Config'
+type MockDeviceWireless_GetPropertyDHCP6Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDHCP6Config is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyDHCP6Config() *MockDeviceWireless_GetPropertyDHCP6Config_Call {
+ return &MockDeviceWireless_GetPropertyDHCP6Config_Call{Call: _e.mock.On("GetPropertyDHCP6Config")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyDHCP6Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyDHCP6Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDHCP6Config_Call) Return(_a0 gonetworkmanager.DHCP6Config, _a1 error) *MockDeviceWireless_GetPropertyDHCP6Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDHCP6Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP6Config, error)) *MockDeviceWireless_GetPropertyDHCP6Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDeviceType provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyDeviceType() (gonetworkmanager.NmDeviceType, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDeviceType")
+ }
+
+ var r0 gonetworkmanager.NmDeviceType
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceType, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceType); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmDeviceType)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyDeviceType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDeviceType'
+type MockDeviceWireless_GetPropertyDeviceType_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDeviceType is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyDeviceType() *MockDeviceWireless_GetPropertyDeviceType_Call {
+ return &MockDeviceWireless_GetPropertyDeviceType_Call{Call: _e.mock.On("GetPropertyDeviceType")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyDeviceType_Call) Run(run func()) *MockDeviceWireless_GetPropertyDeviceType_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDeviceType_Call) Return(_a0 gonetworkmanager.NmDeviceType, _a1 error) *MockDeviceWireless_GetPropertyDeviceType_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDeviceType_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceType, error)) *MockDeviceWireless_GetPropertyDeviceType_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDriver provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyDriver() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDriver")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyDriver_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriver'
+type MockDeviceWireless_GetPropertyDriver_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDriver is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyDriver() *MockDeviceWireless_GetPropertyDriver_Call {
+ return &MockDeviceWireless_GetPropertyDriver_Call{Call: _e.mock.On("GetPropertyDriver")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyDriver_Call) Run(run func()) *MockDeviceWireless_GetPropertyDriver_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDriver_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyDriver_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDriver_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyDriver_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDriverVersion provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyDriverVersion() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDriverVersion")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyDriverVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriverVersion'
+type MockDeviceWireless_GetPropertyDriverVersion_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDriverVersion is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyDriverVersion() *MockDeviceWireless_GetPropertyDriverVersion_Call {
+ return &MockDeviceWireless_GetPropertyDriverVersion_Call{Call: _e.mock.On("GetPropertyDriverVersion")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyDriverVersion_Call) Run(run func()) *MockDeviceWireless_GetPropertyDriverVersion_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDriverVersion_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyDriverVersion_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyDriverVersion_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyDriverVersion_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyFirmwareMissing provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyFirmwareMissing() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyFirmwareMissing")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyFirmwareMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareMissing'
+type MockDeviceWireless_GetPropertyFirmwareMissing_Call struct {
+ *mock.Call
+}
+
+// GetPropertyFirmwareMissing is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyFirmwareMissing() *MockDeviceWireless_GetPropertyFirmwareMissing_Call {
+ return &MockDeviceWireless_GetPropertyFirmwareMissing_Call{Call: _e.mock.On("GetPropertyFirmwareMissing")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyFirmwareMissing_Call) Run(run func()) *MockDeviceWireless_GetPropertyFirmwareMissing_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyFirmwareMissing_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyFirmwareMissing_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyFirmwareMissing_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyFirmwareMissing_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyFirmwareVersion provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyFirmwareVersion() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyFirmwareVersion")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyFirmwareVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareVersion'
+type MockDeviceWireless_GetPropertyFirmwareVersion_Call struct {
+ *mock.Call
+}
+
+// GetPropertyFirmwareVersion is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyFirmwareVersion() *MockDeviceWireless_GetPropertyFirmwareVersion_Call {
+ return &MockDeviceWireless_GetPropertyFirmwareVersion_Call{Call: _e.mock.On("GetPropertyFirmwareVersion")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyFirmwareVersion_Call) Run(run func()) *MockDeviceWireless_GetPropertyFirmwareVersion_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyFirmwareVersion_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyFirmwareVersion_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyFirmwareVersion_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyFirmwareVersion_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyHwAddress provides a mock function with no fields
+func (_m *MockDeviceWireless) 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
+}
+
+// MockDeviceWireless_GetPropertyHwAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHwAddress'
+type MockDeviceWireless_GetPropertyHwAddress_Call struct {
+ *mock.Call
+}
+
+// GetPropertyHwAddress is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyHwAddress() *MockDeviceWireless_GetPropertyHwAddress_Call {
+ return &MockDeviceWireless_GetPropertyHwAddress_Call{Call: _e.mock.On("GetPropertyHwAddress")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyHwAddress_Call) Run(run func()) *MockDeviceWireless_GetPropertyHwAddress_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyHwAddress_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyHwAddress_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyHwAddress_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyHwAddress_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIP4Config provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyIP4Config() (gonetworkmanager.IP4Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIP4Config")
+ }
+
+ var r0 gonetworkmanager.IP4Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP4Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.IP4Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.IP4Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyIP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP4Config'
+type MockDeviceWireless_GetPropertyIP4Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIP4Config is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyIP4Config() *MockDeviceWireless_GetPropertyIP4Config_Call {
+ return &MockDeviceWireless_GetPropertyIP4Config_Call{Call: _e.mock.On("GetPropertyIP4Config")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyIP4Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyIP4Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIP4Config_Call) Return(_a0 gonetworkmanager.IP4Config, _a1 error) *MockDeviceWireless_GetPropertyIP4Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIP4Config_Call) RunAndReturn(run func() (gonetworkmanager.IP4Config, error)) *MockDeviceWireless_GetPropertyIP4Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIP6Config provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyIP6Config() (gonetworkmanager.IP6Config, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIP6Config")
+ }
+
+ var r0 gonetworkmanager.IP6Config
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP6Config, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.IP6Config); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.IP6Config)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyIP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP6Config'
+type MockDeviceWireless_GetPropertyIP6Config_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIP6Config is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyIP6Config() *MockDeviceWireless_GetPropertyIP6Config_Call {
+ return &MockDeviceWireless_GetPropertyIP6Config_Call{Call: _e.mock.On("GetPropertyIP6Config")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyIP6Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyIP6Config_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIP6Config_Call) Return(_a0 gonetworkmanager.IP6Config, _a1 error) *MockDeviceWireless_GetPropertyIP6Config_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIP6Config_Call) RunAndReturn(run func() (gonetworkmanager.IP6Config, error)) *MockDeviceWireless_GetPropertyIP6Config_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyInterface provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyInterface() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyInterface")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyInterface'
+type MockDeviceWireless_GetPropertyInterface_Call struct {
+ *mock.Call
+}
+
+// GetPropertyInterface is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyInterface() *MockDeviceWireless_GetPropertyInterface_Call {
+ return &MockDeviceWireless_GetPropertyInterface_Call{Call: _e.mock.On("GetPropertyInterface")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyInterface_Call) Run(run func()) *MockDeviceWireless_GetPropertyInterface_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyInterface_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyInterface_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyInterface_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyInterface_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIp4Connectivity provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyIp4Connectivity() (gonetworkmanager.NmConnectivity, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIp4Connectivity")
+ }
+
+ var r0 gonetworkmanager.NmConnectivity
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmConnectivity, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmConnectivity); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmConnectivity)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyIp4Connectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIp4Connectivity'
+type MockDeviceWireless_GetPropertyIp4Connectivity_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIp4Connectivity is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyIp4Connectivity() *MockDeviceWireless_GetPropertyIp4Connectivity_Call {
+ return &MockDeviceWireless_GetPropertyIp4Connectivity_Call{Call: _e.mock.On("GetPropertyIp4Connectivity")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyIp4Connectivity_Call) Run(run func()) *MockDeviceWireless_GetPropertyIp4Connectivity_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIp4Connectivity_Call) Return(_a0 gonetworkmanager.NmConnectivity, _a1 error) *MockDeviceWireless_GetPropertyIp4Connectivity_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIp4Connectivity_Call) RunAndReturn(run func() (gonetworkmanager.NmConnectivity, error)) *MockDeviceWireless_GetPropertyIp4Connectivity_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyIpInterface provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyIpInterface() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyIpInterface")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyIpInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIpInterface'
+type MockDeviceWireless_GetPropertyIpInterface_Call struct {
+ *mock.Call
+}
+
+// GetPropertyIpInterface is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyIpInterface() *MockDeviceWireless_GetPropertyIpInterface_Call {
+ return &MockDeviceWireless_GetPropertyIpInterface_Call{Call: _e.mock.On("GetPropertyIpInterface")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyIpInterface_Call) Run(run func()) *MockDeviceWireless_GetPropertyIpInterface_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIpInterface_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyIpInterface_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyIpInterface_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyIpInterface_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyLastScan provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyLastScan() (int64, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyLastScan")
+ }
+
+ var r0 int64
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (int64, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() int64); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(int64)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyLastScan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyLastScan'
+type MockDeviceWireless_GetPropertyLastScan_Call struct {
+ *mock.Call
+}
+
+// GetPropertyLastScan is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyLastScan() *MockDeviceWireless_GetPropertyLastScan_Call {
+ return &MockDeviceWireless_GetPropertyLastScan_Call{Call: _e.mock.On("GetPropertyLastScan")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyLastScan_Call) Run(run func()) *MockDeviceWireless_GetPropertyLastScan_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyLastScan_Call) Return(_a0 int64, _a1 error) *MockDeviceWireless_GetPropertyLastScan_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyLastScan_Call) RunAndReturn(run func() (int64, error)) *MockDeviceWireless_GetPropertyLastScan_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyManaged provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyManaged() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyManaged")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyManaged'
+type MockDeviceWireless_GetPropertyManaged_Call struct {
+ *mock.Call
+}
+
+// GetPropertyManaged is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyManaged() *MockDeviceWireless_GetPropertyManaged_Call {
+ return &MockDeviceWireless_GetPropertyManaged_Call{Call: _e.mock.On("GetPropertyManaged")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyManaged_Call) Run(run func()) *MockDeviceWireless_GetPropertyManaged_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyManaged_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyManaged_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyManaged_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyManaged_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyMode provides a mock function with no fields
+func (_m *MockDeviceWireless) 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
+}
+
+// MockDeviceWireless_GetPropertyMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMode'
+type MockDeviceWireless_GetPropertyMode_Call struct {
+ *mock.Call
+}
+
+// GetPropertyMode is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyMode() *MockDeviceWireless_GetPropertyMode_Call {
+ return &MockDeviceWireless_GetPropertyMode_Call{Call: _e.mock.On("GetPropertyMode")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyMode_Call) Run(run func()) *MockDeviceWireless_GetPropertyMode_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyMode_Call) Return(_a0 gonetworkmanager.Nm80211Mode, _a1 error) *MockDeviceWireless_GetPropertyMode_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyMode_Call) RunAndReturn(run func() (gonetworkmanager.Nm80211Mode, error)) *MockDeviceWireless_GetPropertyMode_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyMtu provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyMtu() (uint32, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyMtu")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyMtu_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMtu'
+type MockDeviceWireless_GetPropertyMtu_Call struct {
+ *mock.Call
+}
+
+// GetPropertyMtu is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyMtu() *MockDeviceWireless_GetPropertyMtu_Call {
+ return &MockDeviceWireless_GetPropertyMtu_Call{Call: _e.mock.On("GetPropertyMtu")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyMtu_Call) Run(run func()) *MockDeviceWireless_GetPropertyMtu_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyMtu_Call) Return(_a0 uint32, _a1 error) *MockDeviceWireless_GetPropertyMtu_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyMtu_Call) RunAndReturn(run func() (uint32, error)) *MockDeviceWireless_GetPropertyMtu_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyNmPluginMissing provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyNmPluginMissing() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyNmPluginMissing")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyNmPluginMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNmPluginMissing'
+type MockDeviceWireless_GetPropertyNmPluginMissing_Call struct {
+ *mock.Call
+}
+
+// GetPropertyNmPluginMissing is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyNmPluginMissing() *MockDeviceWireless_GetPropertyNmPluginMissing_Call {
+ return &MockDeviceWireless_GetPropertyNmPluginMissing_Call{Call: _e.mock.On("GetPropertyNmPluginMissing")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyNmPluginMissing_Call) Run(run func()) *MockDeviceWireless_GetPropertyNmPluginMissing_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyNmPluginMissing_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyNmPluginMissing_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyNmPluginMissing_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyNmPluginMissing_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyPermHwAddress provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyPermHwAddress() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyPermHwAddress")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyPermHwAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPermHwAddress'
+type MockDeviceWireless_GetPropertyPermHwAddress_Call struct {
+ *mock.Call
+}
+
+// GetPropertyPermHwAddress is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyPermHwAddress() *MockDeviceWireless_GetPropertyPermHwAddress_Call {
+ return &MockDeviceWireless_GetPropertyPermHwAddress_Call{Call: _e.mock.On("GetPropertyPermHwAddress")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyPermHwAddress_Call) Run(run func()) *MockDeviceWireless_GetPropertyPermHwAddress_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyPermHwAddress_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyPermHwAddress_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyPermHwAddress_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyPermHwAddress_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyPhysicalPortId provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyPhysicalPortId() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyPhysicalPortId")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyPhysicalPortId_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPhysicalPortId'
+type MockDeviceWireless_GetPropertyPhysicalPortId_Call struct {
+ *mock.Call
+}
+
+// GetPropertyPhysicalPortId is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyPhysicalPortId() *MockDeviceWireless_GetPropertyPhysicalPortId_Call {
+ return &MockDeviceWireless_GetPropertyPhysicalPortId_Call{Call: _e.mock.On("GetPropertyPhysicalPortId")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyPhysicalPortId_Call) Run(run func()) *MockDeviceWireless_GetPropertyPhysicalPortId_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyPhysicalPortId_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyPhysicalPortId_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyPhysicalPortId_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyPhysicalPortId_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyReal provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyReal() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyReal")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyReal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyReal'
+type MockDeviceWireless_GetPropertyReal_Call struct {
+ *mock.Call
+}
+
+// GetPropertyReal is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyReal() *MockDeviceWireless_GetPropertyReal_Call {
+ return &MockDeviceWireless_GetPropertyReal_Call{Call: _e.mock.On("GetPropertyReal")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyReal_Call) Run(run func()) *MockDeviceWireless_GetPropertyReal_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyReal_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyReal_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyReal_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyReal_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyState provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyState() (gonetworkmanager.NmDeviceState, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyState")
+ }
+
+ var r0 gonetworkmanager.NmDeviceState
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceState, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceState); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmDeviceState)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockDeviceWireless_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState'
+type MockDeviceWireless_GetPropertyState_Call struct {
+ *mock.Call
+}
+
+// GetPropertyState is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyState() *MockDeviceWireless_GetPropertyState_Call {
+ return &MockDeviceWireless_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyState_Call) Run(run func()) *MockDeviceWireless_GetPropertyState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmDeviceState, _a1 error) *MockDeviceWireless_GetPropertyState_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceState, error)) *MockDeviceWireless_GetPropertyState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyUdi provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyUdi() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyUdi")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyUdi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUdi'
+type MockDeviceWireless_GetPropertyUdi_Call struct {
+ *mock.Call
+}
+
+// GetPropertyUdi is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyUdi() *MockDeviceWireless_GetPropertyUdi_Call {
+ return &MockDeviceWireless_GetPropertyUdi_Call{Call: _e.mock.On("GetPropertyUdi")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyUdi_Call) Run(run func()) *MockDeviceWireless_GetPropertyUdi_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyUdi_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyUdi_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyUdi_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyUdi_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyWirelessCapabilities provides a mock function with no fields
+func (_m *MockDeviceWireless) GetPropertyWirelessCapabilities() (uint32, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyWirelessCapabilities")
+ }
+
+ 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
+}
+
+// MockDeviceWireless_GetPropertyWirelessCapabilities_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWirelessCapabilities'
+type MockDeviceWireless_GetPropertyWirelessCapabilities_Call struct {
+ *mock.Call
+}
+
+// GetPropertyWirelessCapabilities is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) GetPropertyWirelessCapabilities() *MockDeviceWireless_GetPropertyWirelessCapabilities_Call {
+ return &MockDeviceWireless_GetPropertyWirelessCapabilities_Call{Call: _e.mock.On("GetPropertyWirelessCapabilities")}
+}
+
+func (_c *MockDeviceWireless_GetPropertyWirelessCapabilities_Call) Run(run func()) *MockDeviceWireless_GetPropertyWirelessCapabilities_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyWirelessCapabilities_Call) Return(_a0 uint32, _a1 error) *MockDeviceWireless_GetPropertyWirelessCapabilities_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_GetPropertyWirelessCapabilities_Call) RunAndReturn(run func() (uint32, error)) *MockDeviceWireless_GetPropertyWirelessCapabilities_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// MarshalJSON provides a mock function with no fields
+func (_m *MockDeviceWireless) 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
+}
+
+// MockDeviceWireless_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
+type MockDeviceWireless_MarshalJSON_Call struct {
+ *mock.Call
+}
+
+// MarshalJSON is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) MarshalJSON() *MockDeviceWireless_MarshalJSON_Call {
+ return &MockDeviceWireless_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
+}
+
+func (_c *MockDeviceWireless_MarshalJSON_Call) Run(run func()) *MockDeviceWireless_MarshalJSON_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockDeviceWireless_MarshalJSON_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockDeviceWireless_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockDeviceWireless_MarshalJSON_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Reapply provides a mock function with given fields: connection, versionId, flags
+func (_m *MockDeviceWireless) Reapply(connection gonetworkmanager.Connection, versionId uint64, flags uint32) error {
+ ret := _m.Called(connection, versionId, flags)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Reapply")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, uint64, uint32) error); ok {
+ r0 = rf(connection, versionId, flags)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDeviceWireless_Reapply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reapply'
+type MockDeviceWireless_Reapply_Call struct {
+ *mock.Call
+}
+
+// Reapply is a helper method to define mock.On call
+// - connection gonetworkmanager.Connection
+// - versionId uint64
+// - flags uint32
+func (_e *MockDeviceWireless_Expecter) Reapply(connection interface{}, versionId interface{}, flags interface{}) *MockDeviceWireless_Reapply_Call {
+ return &MockDeviceWireless_Reapply_Call{Call: _e.mock.On("Reapply", connection, versionId, flags)}
+}
+
+func (_c *MockDeviceWireless_Reapply_Call) Run(run func(connection gonetworkmanager.Connection, versionId uint64, flags uint32)) *MockDeviceWireless_Reapply_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.Connection), args[1].(uint64), args[2].(uint32))
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_Reapply_Call) Return(_a0 error) *MockDeviceWireless_Reapply_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDeviceWireless_Reapply_Call) RunAndReturn(run func(gonetworkmanager.Connection, uint64, uint32) error) *MockDeviceWireless_Reapply_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// RequestScan provides a mock function with no fields
+func (_m *MockDeviceWireless) RequestScan() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for RequestScan")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDeviceWireless_RequestScan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestScan'
+type MockDeviceWireless_RequestScan_Call struct {
+ *mock.Call
+}
+
+// RequestScan is a helper method to define mock.On call
+func (_e *MockDeviceWireless_Expecter) RequestScan() *MockDeviceWireless_RequestScan_Call {
+ return &MockDeviceWireless_RequestScan_Call{Call: _e.mock.On("RequestScan")}
+}
+
+func (_c *MockDeviceWireless_RequestScan_Call) Run(run func()) *MockDeviceWireless_RequestScan_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_RequestScan_Call) Return(_a0 error) *MockDeviceWireless_RequestScan_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDeviceWireless_RequestScan_Call) RunAndReturn(run func() error) *MockDeviceWireless_RequestScan_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetPropertyAutoConnect provides a mock function with given fields: _a0
+func (_m *MockDeviceWireless) SetPropertyAutoConnect(_a0 bool) error {
+ ret := _m.Called(_a0)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetPropertyAutoConnect")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(_a0)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDeviceWireless_SetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyAutoConnect'
+type MockDeviceWireless_SetPropertyAutoConnect_Call struct {
+ *mock.Call
+}
+
+// SetPropertyAutoConnect is a helper method to define mock.On call
+// - _a0 bool
+func (_e *MockDeviceWireless_Expecter) SetPropertyAutoConnect(_a0 interface{}) *MockDeviceWireless_SetPropertyAutoConnect_Call {
+ return &MockDeviceWireless_SetPropertyAutoConnect_Call{Call: _e.mock.On("SetPropertyAutoConnect", _a0)}
+}
+
+func (_c *MockDeviceWireless_SetPropertyAutoConnect_Call) Run(run func(_a0 bool)) *MockDeviceWireless_SetPropertyAutoConnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_SetPropertyAutoConnect_Call) Return(_a0 error) *MockDeviceWireless_SetPropertyAutoConnect_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDeviceWireless_SetPropertyAutoConnect_Call) RunAndReturn(run func(bool) error) *MockDeviceWireless_SetPropertyAutoConnect_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetPropertyManaged provides a mock function with given fields: _a0
+func (_m *MockDeviceWireless) SetPropertyManaged(_a0 bool) error {
+ ret := _m.Called(_a0)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetPropertyManaged")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(_a0)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDeviceWireless_SetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyManaged'
+type MockDeviceWireless_SetPropertyManaged_Call struct {
+ *mock.Call
+}
+
+// SetPropertyManaged is a helper method to define mock.On call
+// - _a0 bool
+func (_e *MockDeviceWireless_Expecter) SetPropertyManaged(_a0 interface{}) *MockDeviceWireless_SetPropertyManaged_Call {
+ return &MockDeviceWireless_SetPropertyManaged_Call{Call: _e.mock.On("SetPropertyManaged", _a0)}
+}
+
+func (_c *MockDeviceWireless_SetPropertyManaged_Call) Run(run func(_a0 bool)) *MockDeviceWireless_SetPropertyManaged_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_SetPropertyManaged_Call) Return(_a0 error) *MockDeviceWireless_SetPropertyManaged_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockDeviceWireless_SetPropertyManaged_Call) RunAndReturn(run func(bool) error) *MockDeviceWireless_SetPropertyManaged_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SubscribeState provides a mock function with given fields: receiver, exit
+func (_m *MockDeviceWireless) SubscribeState(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{}) error {
+ ret := _m.Called(receiver, exit)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SubscribeState")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error); ok {
+ r0 = rf(receiver, exit)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockDeviceWireless_SubscribeState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribeState'
+type MockDeviceWireless_SubscribeState_Call struct {
+ *mock.Call
+}
+
+// SubscribeState is a helper method to define mock.On call
+// - receiver chan gonetworkmanager.DeviceStateChange
+// - exit chan struct{}
+func (_e *MockDeviceWireless_Expecter) SubscribeState(receiver interface{}, exit interface{}) *MockDeviceWireless_SubscribeState_Call {
+ return &MockDeviceWireless_SubscribeState_Call{Call: _e.mock.On("SubscribeState", receiver, exit)}
+}
+
+func (_c *MockDeviceWireless_SubscribeState_Call) Run(run func(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{})) *MockDeviceWireless_SubscribeState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(chan gonetworkmanager.DeviceStateChange), args[1].(chan struct{}))
+ })
+ return _c
+}
+
+func (_c *MockDeviceWireless_SubscribeState_Call) Return(err error) *MockDeviceWireless_SubscribeState_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockDeviceWireless_SubscribeState_Call) RunAndReturn(run func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error) *MockDeviceWireless_SubscribeState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockDeviceWireless creates a new instance of MockDeviceWireless. 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 NewMockDeviceWireless(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockDeviceWireless {
+ mock := &MockDeviceWireless{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_IP4Config.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_IP4Config.go
new file mode 100644
index 00000000..235be867
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_IP4Config.go
@@ -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
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_NetworkManager.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_NetworkManager.go
new file mode 100644
index 00000000..e74ffb04
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_NetworkManager.go
@@ -0,0 +1,2349 @@
+// 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"
+)
+
+// MockNetworkManager is an autogenerated mock type for the NetworkManager type
+type MockNetworkManager struct {
+ mock.Mock
+}
+
+type MockNetworkManager_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockNetworkManager) EXPECT() *MockNetworkManager_Expecter {
+ return &MockNetworkManager_Expecter{mock: &_m.Mock}
+}
+
+// ActivateConnection provides a mock function with given fields: connection, device, specificObject
+func (_m *MockNetworkManager) ActivateConnection(connection gonetworkmanager.Connection, device gonetworkmanager.Device, specificObject *dbus.Object) (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called(connection, device, specificObject)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ActivateConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf(connection, device, specificObject)
+ }
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) gonetworkmanager.ActiveConnection); ok {
+ r0 = rf(connection, device, specificObject)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) error); ok {
+ r1 = rf(connection, device, specificObject)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_ActivateConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ActivateConnection'
+type MockNetworkManager_ActivateConnection_Call struct {
+ *mock.Call
+}
+
+// ActivateConnection is a helper method to define mock.On call
+// - connection gonetworkmanager.Connection
+// - device gonetworkmanager.Device
+// - specificObject *dbus.Object
+func (_e *MockNetworkManager_Expecter) ActivateConnection(connection interface{}, device interface{}, specificObject interface{}) *MockNetworkManager_ActivateConnection_Call {
+ return &MockNetworkManager_ActivateConnection_Call{Call: _e.mock.On("ActivateConnection", connection, device, specificObject)}
+}
+
+func (_c *MockNetworkManager_ActivateConnection_Call) Run(run func(connection gonetworkmanager.Connection, device gonetworkmanager.Device, specificObject *dbus.Object)) *MockNetworkManager_ActivateConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.Connection), args[1].(gonetworkmanager.Device), args[2].(*dbus.Object))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_ActivateConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_ActivateConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_ActivateConnection_Call) RunAndReturn(run func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_ActivateConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ActivateWirelessConnection provides a mock function with given fields: connection, device, accessPoint
+func (_m *MockNetworkManager) ActivateWirelessConnection(connection gonetworkmanager.Connection, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called(connection, device, accessPoint)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ActivateWirelessConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf(connection, device, accessPoint)
+ }
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) gonetworkmanager.ActiveConnection); ok {
+ r0 = rf(connection, device, accessPoint)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) error); ok {
+ r1 = rf(connection, device, accessPoint)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_ActivateWirelessConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ActivateWirelessConnection'
+type MockNetworkManager_ActivateWirelessConnection_Call struct {
+ *mock.Call
+}
+
+// ActivateWirelessConnection is a helper method to define mock.On call
+// - connection gonetworkmanager.Connection
+// - device gonetworkmanager.Device
+// - accessPoint gonetworkmanager.AccessPoint
+func (_e *MockNetworkManager_Expecter) ActivateWirelessConnection(connection interface{}, device interface{}, accessPoint interface{}) *MockNetworkManager_ActivateWirelessConnection_Call {
+ return &MockNetworkManager_ActivateWirelessConnection_Call{Call: _e.mock.On("ActivateWirelessConnection", connection, device, accessPoint)}
+}
+
+func (_c *MockNetworkManager_ActivateWirelessConnection_Call) Run(run func(connection gonetworkmanager.Connection, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint)) *MockNetworkManager_ActivateWirelessConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.Connection), args[1].(gonetworkmanager.Device), args[2].(gonetworkmanager.AccessPoint))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_ActivateWirelessConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_ActivateWirelessConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_ActivateWirelessConnection_Call) RunAndReturn(run func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_ActivateWirelessConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// AddAndActivateConnection provides a mock function with given fields: connection, device
+func (_m *MockNetworkManager) AddAndActivateConnection(connection map[string]map[string]interface{}, device gonetworkmanager.Device) (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called(connection, device)
+
+ if len(ret) == 0 {
+ panic("no return value specified for AddAndActivateConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device) (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf(connection, device)
+ }
+ if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device) gonetworkmanager.ActiveConnection); ok {
+ r0 = rf(connection, device)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(map[string]map[string]interface{}, gonetworkmanager.Device) error); ok {
+ r1 = rf(connection, device)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_AddAndActivateConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAndActivateConnection'
+type MockNetworkManager_AddAndActivateConnection_Call struct {
+ *mock.Call
+}
+
+// AddAndActivateConnection is a helper method to define mock.On call
+// - connection map[string]map[string]interface{}
+// - device gonetworkmanager.Device
+func (_e *MockNetworkManager_Expecter) AddAndActivateConnection(connection interface{}, device interface{}) *MockNetworkManager_AddAndActivateConnection_Call {
+ return &MockNetworkManager_AddAndActivateConnection_Call{Call: _e.mock.On("AddAndActivateConnection", connection, device)}
+}
+
+func (_c *MockNetworkManager_AddAndActivateConnection_Call) Run(run func(connection map[string]map[string]interface{}, device gonetworkmanager.Device)) *MockNetworkManager_AddAndActivateConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(map[string]map[string]interface{}), args[1].(gonetworkmanager.Device))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_AddAndActivateConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_AddAndActivateConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_AddAndActivateConnection_Call) RunAndReturn(run func(map[string]map[string]interface{}, gonetworkmanager.Device) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_AddAndActivateConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// AddAndActivateWirelessConnection provides a mock function with given fields: connection, device, accessPoint
+func (_m *MockNetworkManager) AddAndActivateWirelessConnection(connection map[string]map[string]interface{}, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called(connection, device, accessPoint)
+
+ if len(ret) == 0 {
+ panic("no return value specified for AddAndActivateWirelessConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf(connection, device, accessPoint)
+ }
+ if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) gonetworkmanager.ActiveConnection); ok {
+ r0 = rf(connection, device, accessPoint)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) error); ok {
+ r1 = rf(connection, device, accessPoint)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_AddAndActivateWirelessConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAndActivateWirelessConnection'
+type MockNetworkManager_AddAndActivateWirelessConnection_Call struct {
+ *mock.Call
+}
+
+// AddAndActivateWirelessConnection is a helper method to define mock.On call
+// - connection map[string]map[string]interface{}
+// - device gonetworkmanager.Device
+// - accessPoint gonetworkmanager.AccessPoint
+func (_e *MockNetworkManager_Expecter) AddAndActivateWirelessConnection(connection interface{}, device interface{}, accessPoint interface{}) *MockNetworkManager_AddAndActivateWirelessConnection_Call {
+ return &MockNetworkManager_AddAndActivateWirelessConnection_Call{Call: _e.mock.On("AddAndActivateWirelessConnection", connection, device, accessPoint)}
+}
+
+func (_c *MockNetworkManager_AddAndActivateWirelessConnection_Call) Run(run func(connection map[string]map[string]interface{}, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint)) *MockNetworkManager_AddAndActivateWirelessConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(map[string]map[string]interface{}), args[1].(gonetworkmanager.Device), args[2].(gonetworkmanager.AccessPoint))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_AddAndActivateWirelessConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_AddAndActivateWirelessConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_AddAndActivateWirelessConnection_Call) RunAndReturn(run func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_AddAndActivateWirelessConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// CheckConnectivity provides a mock function with no fields
+func (_m *MockNetworkManager) CheckConnectivity() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for CheckConnectivity")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_CheckConnectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckConnectivity'
+type MockNetworkManager_CheckConnectivity_Call struct {
+ *mock.Call
+}
+
+// CheckConnectivity is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) CheckConnectivity() *MockNetworkManager_CheckConnectivity_Call {
+ return &MockNetworkManager_CheckConnectivity_Call{Call: _e.mock.On("CheckConnectivity")}
+}
+
+func (_c *MockNetworkManager_CheckConnectivity_Call) Run(run func()) *MockNetworkManager_CheckConnectivity_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckConnectivity_Call) Return(_a0 error) *MockNetworkManager_CheckConnectivity_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckConnectivity_Call) RunAndReturn(run func() error) *MockNetworkManager_CheckConnectivity_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// CheckpointAdjustRollbackTimeout provides a mock function with given fields: checkpoint, addTimeout
+func (_m *MockNetworkManager) CheckpointAdjustRollbackTimeout(checkpoint gonetworkmanager.Checkpoint, addTimeout uint32) error {
+ ret := _m.Called(checkpoint, addTimeout)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CheckpointAdjustRollbackTimeout")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint, uint32) error); ok {
+ r0 = rf(checkpoint, addTimeout)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_CheckpointAdjustRollbackTimeout_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointAdjustRollbackTimeout'
+type MockNetworkManager_CheckpointAdjustRollbackTimeout_Call struct {
+ *mock.Call
+}
+
+// CheckpointAdjustRollbackTimeout is a helper method to define mock.On call
+// - checkpoint gonetworkmanager.Checkpoint
+// - addTimeout uint32
+func (_e *MockNetworkManager_Expecter) CheckpointAdjustRollbackTimeout(checkpoint interface{}, addTimeout interface{}) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call {
+ return &MockNetworkManager_CheckpointAdjustRollbackTimeout_Call{Call: _e.mock.On("CheckpointAdjustRollbackTimeout", checkpoint, addTimeout)}
+}
+
+func (_c *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call) Run(run func(checkpoint gonetworkmanager.Checkpoint, addTimeout uint32)) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.Checkpoint), args[1].(uint32))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call) Return(_a0 error) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call) RunAndReturn(run func(gonetworkmanager.Checkpoint, uint32) error) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// CheckpointCreate provides a mock function with given fields: devices, rollbackTimeout, flags
+func (_m *MockNetworkManager) CheckpointCreate(devices []gonetworkmanager.Device, rollbackTimeout uint32, flags uint32) (gonetworkmanager.Checkpoint, error) {
+ ret := _m.Called(devices, rollbackTimeout, flags)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CheckpointCreate")
+ }
+
+ var r0 gonetworkmanager.Checkpoint
+ var r1 error
+ if rf, ok := ret.Get(0).(func([]gonetworkmanager.Device, uint32, uint32) (gonetworkmanager.Checkpoint, error)); ok {
+ return rf(devices, rollbackTimeout, flags)
+ }
+ if rf, ok := ret.Get(0).(func([]gonetworkmanager.Device, uint32, uint32) gonetworkmanager.Checkpoint); ok {
+ r0 = rf(devices, rollbackTimeout, flags)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.Checkpoint)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func([]gonetworkmanager.Device, uint32, uint32) error); ok {
+ r1 = rf(devices, rollbackTimeout, flags)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_CheckpointCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointCreate'
+type MockNetworkManager_CheckpointCreate_Call struct {
+ *mock.Call
+}
+
+// CheckpointCreate is a helper method to define mock.On call
+// - devices []gonetworkmanager.Device
+// - rollbackTimeout uint32
+// - flags uint32
+func (_e *MockNetworkManager_Expecter) CheckpointCreate(devices interface{}, rollbackTimeout interface{}, flags interface{}) *MockNetworkManager_CheckpointCreate_Call {
+ return &MockNetworkManager_CheckpointCreate_Call{Call: _e.mock.On("CheckpointCreate", devices, rollbackTimeout, flags)}
+}
+
+func (_c *MockNetworkManager_CheckpointCreate_Call) Run(run func(devices []gonetworkmanager.Device, rollbackTimeout uint32, flags uint32)) *MockNetworkManager_CheckpointCreate_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]gonetworkmanager.Device), args[1].(uint32), args[2].(uint32))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointCreate_Call) Return(_a0 gonetworkmanager.Checkpoint, _a1 error) *MockNetworkManager_CheckpointCreate_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointCreate_Call) RunAndReturn(run func([]gonetworkmanager.Device, uint32, uint32) (gonetworkmanager.Checkpoint, error)) *MockNetworkManager_CheckpointCreate_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// CheckpointDestroy provides a mock function with given fields: checkpoint
+func (_m *MockNetworkManager) CheckpointDestroy(checkpoint gonetworkmanager.Checkpoint) error {
+ ret := _m.Called(checkpoint)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CheckpointDestroy")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint) error); ok {
+ r0 = rf(checkpoint)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_CheckpointDestroy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointDestroy'
+type MockNetworkManager_CheckpointDestroy_Call struct {
+ *mock.Call
+}
+
+// CheckpointDestroy is a helper method to define mock.On call
+// - checkpoint gonetworkmanager.Checkpoint
+func (_e *MockNetworkManager_Expecter) CheckpointDestroy(checkpoint interface{}) *MockNetworkManager_CheckpointDestroy_Call {
+ return &MockNetworkManager_CheckpointDestroy_Call{Call: _e.mock.On("CheckpointDestroy", checkpoint)}
+}
+
+func (_c *MockNetworkManager_CheckpointDestroy_Call) Run(run func(checkpoint gonetworkmanager.Checkpoint)) *MockNetworkManager_CheckpointDestroy_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.Checkpoint))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointDestroy_Call) Return(_a0 error) *MockNetworkManager_CheckpointDestroy_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointDestroy_Call) RunAndReturn(run func(gonetworkmanager.Checkpoint) error) *MockNetworkManager_CheckpointDestroy_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// CheckpointRollback provides a mock function with given fields: checkpoint
+func (_m *MockNetworkManager) CheckpointRollback(checkpoint gonetworkmanager.Checkpoint) (map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, error) {
+ ret := _m.Called(checkpoint)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CheckpointRollback")
+ }
+
+ var r0 map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult
+ var r1 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint) (map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, error)); ok {
+ return rf(checkpoint)
+ }
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint) map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult); ok {
+ r0 = rf(checkpoint)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(gonetworkmanager.Checkpoint) error); ok {
+ r1 = rf(checkpoint)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_CheckpointRollback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointRollback'
+type MockNetworkManager_CheckpointRollback_Call struct {
+ *mock.Call
+}
+
+// CheckpointRollback is a helper method to define mock.On call
+// - checkpoint gonetworkmanager.Checkpoint
+func (_e *MockNetworkManager_Expecter) CheckpointRollback(checkpoint interface{}) *MockNetworkManager_CheckpointRollback_Call {
+ return &MockNetworkManager_CheckpointRollback_Call{Call: _e.mock.On("CheckpointRollback", checkpoint)}
+}
+
+func (_c *MockNetworkManager_CheckpointRollback_Call) Run(run func(checkpoint gonetworkmanager.Checkpoint)) *MockNetworkManager_CheckpointRollback_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.Checkpoint))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointRollback_Call) Return(result map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, err error) *MockNetworkManager_CheckpointRollback_Call {
+ _c.Call.Return(result, err)
+ return _c
+}
+
+func (_c *MockNetworkManager_CheckpointRollback_Call) RunAndReturn(run func(gonetworkmanager.Checkpoint) (map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, error)) *MockNetworkManager_CheckpointRollback_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// DeactivateConnection provides a mock function with given fields: connection
+func (_m *MockNetworkManager) DeactivateConnection(connection gonetworkmanager.ActiveConnection) error {
+ ret := _m.Called(connection)
+
+ if len(ret) == 0 {
+ panic("no return value specified for DeactivateConnection")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.ActiveConnection) error); ok {
+ r0 = rf(connection)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_DeactivateConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeactivateConnection'
+type MockNetworkManager_DeactivateConnection_Call struct {
+ *mock.Call
+}
+
+// DeactivateConnection is a helper method to define mock.On call
+// - connection gonetworkmanager.ActiveConnection
+func (_e *MockNetworkManager_Expecter) DeactivateConnection(connection interface{}) *MockNetworkManager_DeactivateConnection_Call {
+ return &MockNetworkManager_DeactivateConnection_Call{Call: _e.mock.On("DeactivateConnection", connection)}
+}
+
+func (_c *MockNetworkManager_DeactivateConnection_Call) Run(run func(connection gonetworkmanager.ActiveConnection)) *MockNetworkManager_DeactivateConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.ActiveConnection))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_DeactivateConnection_Call) Return(_a0 error) *MockNetworkManager_DeactivateConnection_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_DeactivateConnection_Call) RunAndReturn(run func(gonetworkmanager.ActiveConnection) error) *MockNetworkManager_DeactivateConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Enable provides a mock function with given fields: enableNDisable
+func (_m *MockNetworkManager) Enable(enableNDisable bool) error {
+ ret := _m.Called(enableNDisable)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Enable")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(enableNDisable)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_Enable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enable'
+type MockNetworkManager_Enable_Call struct {
+ *mock.Call
+}
+
+// Enable is a helper method to define mock.On call
+// - enableNDisable bool
+func (_e *MockNetworkManager_Expecter) Enable(enableNDisable interface{}) *MockNetworkManager_Enable_Call {
+ return &MockNetworkManager_Enable_Call{Call: _e.mock.On("Enable", enableNDisable)}
+}
+
+func (_c *MockNetworkManager_Enable_Call) Run(run func(enableNDisable bool)) *MockNetworkManager_Enable_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_Enable_Call) Return(_a0 error) *MockNetworkManager_Enable_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_Enable_Call) RunAndReturn(run func(bool) error) *MockNetworkManager_Enable_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetAllDevices provides a mock function with no fields
+func (_m *MockNetworkManager) GetAllDevices() ([]gonetworkmanager.Device, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetAllDevices")
+ }
+
+ var r0 []gonetworkmanager.Device
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Device)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetAllDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllDevices'
+type MockNetworkManager_GetAllDevices_Call struct {
+ *mock.Call
+}
+
+// GetAllDevices is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetAllDevices() *MockNetworkManager_GetAllDevices_Call {
+ return &MockNetworkManager_GetAllDevices_Call{Call: _e.mock.On("GetAllDevices")}
+}
+
+func (_c *MockNetworkManager_GetAllDevices_Call) Run(run func()) *MockNetworkManager_GetAllDevices_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetAllDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetAllDevices_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetAllDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetAllDevices_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetDeviceByIpIface provides a mock function with given fields: interfaceId
+func (_m *MockNetworkManager) GetDeviceByIpIface(interfaceId string) (gonetworkmanager.Device, error) {
+ ret := _m.Called(interfaceId)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetDeviceByIpIface")
+ }
+
+ var r0 gonetworkmanager.Device
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.Device, error)); ok {
+ return rf(interfaceId)
+ }
+ if rf, ok := ret.Get(0).(func(string) gonetworkmanager.Device); ok {
+ r0 = rf(interfaceId)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.Device)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(interfaceId)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetDeviceByIpIface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDeviceByIpIface'
+type MockNetworkManager_GetDeviceByIpIface_Call struct {
+ *mock.Call
+}
+
+// GetDeviceByIpIface is a helper method to define mock.On call
+// - interfaceId string
+func (_e *MockNetworkManager_Expecter) GetDeviceByIpIface(interfaceId interface{}) *MockNetworkManager_GetDeviceByIpIface_Call {
+ return &MockNetworkManager_GetDeviceByIpIface_Call{Call: _e.mock.On("GetDeviceByIpIface", interfaceId)}
+}
+
+func (_c *MockNetworkManager_GetDeviceByIpIface_Call) Run(run func(interfaceId string)) *MockNetworkManager_GetDeviceByIpIface_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetDeviceByIpIface_Call) Return(_a0 gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetDeviceByIpIface_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetDeviceByIpIface_Call) RunAndReturn(run func(string) (gonetworkmanager.Device, error)) *MockNetworkManager_GetDeviceByIpIface_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetDevices provides a mock function with no fields
+func (_m *MockNetworkManager) GetDevices() ([]gonetworkmanager.Device, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetDevices")
+ }
+
+ var r0 []gonetworkmanager.Device
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Device)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDevices'
+type MockNetworkManager_GetDevices_Call struct {
+ *mock.Call
+}
+
+// GetDevices is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetDevices() *MockNetworkManager_GetDevices_Call {
+ return &MockNetworkManager_GetDevices_Call{Call: _e.mock.On("GetDevices")}
+}
+
+func (_c *MockNetworkManager_GetDevices_Call) Run(run func()) *MockNetworkManager_GetDevices_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetDevices_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetDevices_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyActivatingConnection provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyActivatingConnection() (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyActivatingConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyActivatingConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActivatingConnection'
+type MockNetworkManager_GetPropertyActivatingConnection_Call struct {
+ *mock.Call
+}
+
+// GetPropertyActivatingConnection is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyActivatingConnection() *MockNetworkManager_GetPropertyActivatingConnection_Call {
+ return &MockNetworkManager_GetPropertyActivatingConnection_Call{Call: _e.mock.On("GetPropertyActivatingConnection")}
+}
+
+func (_c *MockNetworkManager_GetPropertyActivatingConnection_Call) Run(run func()) *MockNetworkManager_GetPropertyActivatingConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyActivatingConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_GetPropertyActivatingConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyActivatingConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_GetPropertyActivatingConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyActiveConnections provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyActiveConnections() ([]gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyActiveConnections")
+ }
+
+ var r0 []gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.ActiveConnection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.ActiveConnection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyActiveConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveConnections'
+type MockNetworkManager_GetPropertyActiveConnections_Call struct {
+ *mock.Call
+}
+
+// GetPropertyActiveConnections is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyActiveConnections() *MockNetworkManager_GetPropertyActiveConnections_Call {
+ return &MockNetworkManager_GetPropertyActiveConnections_Call{Call: _e.mock.On("GetPropertyActiveConnections")}
+}
+
+func (_c *MockNetworkManager_GetPropertyActiveConnections_Call) Run(run func()) *MockNetworkManager_GetPropertyActiveConnections_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyActiveConnections_Call) Return(_a0 []gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_GetPropertyActiveConnections_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyActiveConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_GetPropertyActiveConnections_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyAllDevices provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyAllDevices() ([]gonetworkmanager.Device, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyAllDevices")
+ }
+
+ var r0 []gonetworkmanager.Device
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Device)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyAllDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAllDevices'
+type MockNetworkManager_GetPropertyAllDevices_Call struct {
+ *mock.Call
+}
+
+// GetPropertyAllDevices is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyAllDevices() *MockNetworkManager_GetPropertyAllDevices_Call {
+ return &MockNetworkManager_GetPropertyAllDevices_Call{Call: _e.mock.On("GetPropertyAllDevices")}
+}
+
+func (_c *MockNetworkManager_GetPropertyAllDevices_Call) Run(run func()) *MockNetworkManager_GetPropertyAllDevices_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyAllDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetPropertyAllDevices_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyAllDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetPropertyAllDevices_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyCapabilities provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyCapabilities() ([]gonetworkmanager.NmCapability, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyCapabilities")
+ }
+
+ var r0 []gonetworkmanager.NmCapability
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.NmCapability, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.NmCapability); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.NmCapability)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyCapabilities_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyCapabilities'
+type MockNetworkManager_GetPropertyCapabilities_Call struct {
+ *mock.Call
+}
+
+// GetPropertyCapabilities is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyCapabilities() *MockNetworkManager_GetPropertyCapabilities_Call {
+ return &MockNetworkManager_GetPropertyCapabilities_Call{Call: _e.mock.On("GetPropertyCapabilities")}
+}
+
+func (_c *MockNetworkManager_GetPropertyCapabilities_Call) Run(run func()) *MockNetworkManager_GetPropertyCapabilities_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyCapabilities_Call) Return(_a0 []gonetworkmanager.NmCapability, _a1 error) *MockNetworkManager_GetPropertyCapabilities_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyCapabilities_Call) RunAndReturn(run func() ([]gonetworkmanager.NmCapability, error)) *MockNetworkManager_GetPropertyCapabilities_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyCheckpoints provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyCheckpoints() ([]gonetworkmanager.Checkpoint, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyCheckpoints")
+ }
+
+ var r0 []gonetworkmanager.Checkpoint
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Checkpoint, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Checkpoint); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Checkpoint)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyCheckpoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyCheckpoints'
+type MockNetworkManager_GetPropertyCheckpoints_Call struct {
+ *mock.Call
+}
+
+// GetPropertyCheckpoints is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyCheckpoints() *MockNetworkManager_GetPropertyCheckpoints_Call {
+ return &MockNetworkManager_GetPropertyCheckpoints_Call{Call: _e.mock.On("GetPropertyCheckpoints")}
+}
+
+func (_c *MockNetworkManager_GetPropertyCheckpoints_Call) Run(run func()) *MockNetworkManager_GetPropertyCheckpoints_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyCheckpoints_Call) Return(_a0 []gonetworkmanager.Checkpoint, _a1 error) *MockNetworkManager_GetPropertyCheckpoints_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyCheckpoints_Call) RunAndReturn(run func() ([]gonetworkmanager.Checkpoint, error)) *MockNetworkManager_GetPropertyCheckpoints_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyConnectivity provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyConnectivity() (gonetworkmanager.NmConnectivity, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyConnectivity")
+ }
+
+ var r0 gonetworkmanager.NmConnectivity
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmConnectivity, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmConnectivity); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmConnectivity)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyConnectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnectivity'
+type MockNetworkManager_GetPropertyConnectivity_Call struct {
+ *mock.Call
+}
+
+// GetPropertyConnectivity is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyConnectivity() *MockNetworkManager_GetPropertyConnectivity_Call {
+ return &MockNetworkManager_GetPropertyConnectivity_Call{Call: _e.mock.On("GetPropertyConnectivity")}
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivity_Call) Run(run func()) *MockNetworkManager_GetPropertyConnectivity_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivity_Call) Return(_a0 gonetworkmanager.NmConnectivity, _a1 error) *MockNetworkManager_GetPropertyConnectivity_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivity_Call) RunAndReturn(run func() (gonetworkmanager.NmConnectivity, error)) *MockNetworkManager_GetPropertyConnectivity_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyConnectivityCheckAvailable provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyConnectivityCheckAvailable() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyConnectivityCheckAvailable")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnectivityCheckAvailable'
+type MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call struct {
+ *mock.Call
+}
+
+// GetPropertyConnectivityCheckAvailable is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyConnectivityCheckAvailable() *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call {
+ return &MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call{Call: _e.mock.On("GetPropertyConnectivityCheckAvailable")}
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call) Run(run func()) *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyConnectivityCheckEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyConnectivityCheckEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyConnectivityCheckEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnectivityCheckEnabled'
+type MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyConnectivityCheckEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyConnectivityCheckEnabled() *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call {
+ return &MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call{Call: _e.mock.On("GetPropertyConnectivityCheckEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyDevices provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyDevices() ([]gonetworkmanager.Device, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyDevices")
+ }
+
+ var r0 []gonetworkmanager.Device
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Device)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDevices'
+type MockNetworkManager_GetPropertyDevices_Call struct {
+ *mock.Call
+}
+
+// GetPropertyDevices is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyDevices() *MockNetworkManager_GetPropertyDevices_Call {
+ return &MockNetworkManager_GetPropertyDevices_Call{Call: _e.mock.On("GetPropertyDevices")}
+}
+
+func (_c *MockNetworkManager_GetPropertyDevices_Call) Run(run func()) *MockNetworkManager_GetPropertyDevices_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetPropertyDevices_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetPropertyDevices_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyMetered provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyMetered() (gonetworkmanager.NmMetered, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyMetered")
+ }
+
+ var r0 gonetworkmanager.NmMetered
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmMetered, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmMetered); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmMetered)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyMetered_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMetered'
+type MockNetworkManager_GetPropertyMetered_Call struct {
+ *mock.Call
+}
+
+// GetPropertyMetered is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyMetered() *MockNetworkManager_GetPropertyMetered_Call {
+ return &MockNetworkManager_GetPropertyMetered_Call{Call: _e.mock.On("GetPropertyMetered")}
+}
+
+func (_c *MockNetworkManager_GetPropertyMetered_Call) Run(run func()) *MockNetworkManager_GetPropertyMetered_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyMetered_Call) Return(_a0 gonetworkmanager.NmMetered, _a1 error) *MockNetworkManager_GetPropertyMetered_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyMetered_Call) RunAndReturn(run func() (gonetworkmanager.NmMetered, error)) *MockNetworkManager_GetPropertyMetered_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyNetworkingEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyNetworkingEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyNetworkingEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyNetworkingEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNetworkingEnabled'
+type MockNetworkManager_GetPropertyNetworkingEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyNetworkingEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyNetworkingEnabled() *MockNetworkManager_GetPropertyNetworkingEnabled_Call {
+ return &MockNetworkManager_GetPropertyNetworkingEnabled_Call{Call: _e.mock.On("GetPropertyNetworkingEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyNetworkingEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyNetworkingEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyNetworkingEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyNetworkingEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyNetworkingEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyNetworkingEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyPrimaryConnection provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyPrimaryConnection() (gonetworkmanager.ActiveConnection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyPrimaryConnection")
+ }
+
+ var r0 gonetworkmanager.ActiveConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.ActiveConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyPrimaryConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPrimaryConnection'
+type MockNetworkManager_GetPropertyPrimaryConnection_Call struct {
+ *mock.Call
+}
+
+// GetPropertyPrimaryConnection is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyPrimaryConnection() *MockNetworkManager_GetPropertyPrimaryConnection_Call {
+ return &MockNetworkManager_GetPropertyPrimaryConnection_Call{Call: _e.mock.On("GetPropertyPrimaryConnection")}
+}
+
+func (_c *MockNetworkManager_GetPropertyPrimaryConnection_Call) Run(run func()) *MockNetworkManager_GetPropertyPrimaryConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyPrimaryConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_GetPropertyPrimaryConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyPrimaryConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_GetPropertyPrimaryConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyPrimaryConnectionType provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyPrimaryConnectionType() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyPrimaryConnectionType")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyPrimaryConnectionType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPrimaryConnectionType'
+type MockNetworkManager_GetPropertyPrimaryConnectionType_Call struct {
+ *mock.Call
+}
+
+// GetPropertyPrimaryConnectionType is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyPrimaryConnectionType() *MockNetworkManager_GetPropertyPrimaryConnectionType_Call {
+ return &MockNetworkManager_GetPropertyPrimaryConnectionType_Call{Call: _e.mock.On("GetPropertyPrimaryConnectionType")}
+}
+
+func (_c *MockNetworkManager_GetPropertyPrimaryConnectionType_Call) Run(run func()) *MockNetworkManager_GetPropertyPrimaryConnectionType_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyPrimaryConnectionType_Call) Return(_a0 string, _a1 error) *MockNetworkManager_GetPropertyPrimaryConnectionType_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyPrimaryConnectionType_Call) RunAndReturn(run func() (string, error)) *MockNetworkManager_GetPropertyPrimaryConnectionType_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyStartup provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyStartup() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyStartup")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyStartup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyStartup'
+type MockNetworkManager_GetPropertyStartup_Call struct {
+ *mock.Call
+}
+
+// GetPropertyStartup is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyStartup() *MockNetworkManager_GetPropertyStartup_Call {
+ return &MockNetworkManager_GetPropertyStartup_Call{Call: _e.mock.On("GetPropertyStartup")}
+}
+
+func (_c *MockNetworkManager_GetPropertyStartup_Call) Run(run func()) *MockNetworkManager_GetPropertyStartup_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyStartup_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyStartup_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyStartup_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyStartup_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyState provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyState() (gonetworkmanager.NmState, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyState")
+ }
+
+ var r0 gonetworkmanager.NmState
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmState, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmState); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmState)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState'
+type MockNetworkManager_GetPropertyState_Call struct {
+ *mock.Call
+}
+
+// GetPropertyState is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyState() *MockNetworkManager_GetPropertyState_Call {
+ return &MockNetworkManager_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")}
+}
+
+func (_c *MockNetworkManager_GetPropertyState_Call) Run(run func()) *MockNetworkManager_GetPropertyState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmState, _a1 error) *MockNetworkManager_GetPropertyState_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmState, error)) *MockNetworkManager_GetPropertyState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyVersion provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyVersion() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyVersion")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyVersion'
+type MockNetworkManager_GetPropertyVersion_Call struct {
+ *mock.Call
+}
+
+// GetPropertyVersion is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyVersion() *MockNetworkManager_GetPropertyVersion_Call {
+ return &MockNetworkManager_GetPropertyVersion_Call{Call: _e.mock.On("GetPropertyVersion")}
+}
+
+func (_c *MockNetworkManager_GetPropertyVersion_Call) Run(run func()) *MockNetworkManager_GetPropertyVersion_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyVersion_Call) Return(_a0 string, _a1 error) *MockNetworkManager_GetPropertyVersion_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyVersion_Call) RunAndReturn(run func() (string, error)) *MockNetworkManager_GetPropertyVersion_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyWimaxEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyWimaxEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyWimaxEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyWimaxEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWimaxEnabled'
+type MockNetworkManager_GetPropertyWimaxEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyWimaxEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyWimaxEnabled() *MockNetworkManager_GetPropertyWimaxEnabled_Call {
+ return &MockNetworkManager_GetPropertyWimaxEnabled_Call{Call: _e.mock.On("GetPropertyWimaxEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyWimaxEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWimaxEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWimaxEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWimaxEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWimaxEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWimaxEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyWimaxHardwareEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyWimaxHardwareEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyWimaxHardwareEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWimaxHardwareEnabled'
+type MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyWimaxHardwareEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyWimaxHardwareEnabled() *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call {
+ return &MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call{Call: _e.mock.On("GetPropertyWimaxHardwareEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyWirelessEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyWirelessEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyWirelessEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyWirelessEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWirelessEnabled'
+type MockNetworkManager_GetPropertyWirelessEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyWirelessEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyWirelessEnabled() *MockNetworkManager_GetPropertyWirelessEnabled_Call {
+ return &MockNetworkManager_GetPropertyWirelessEnabled_Call{Call: _e.mock.On("GetPropertyWirelessEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyWirelessEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWirelessEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWirelessEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWirelessEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWirelessEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWirelessEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyWirelessHardwareEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyWirelessHardwareEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyWirelessHardwareEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWirelessHardwareEnabled'
+type MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyWirelessHardwareEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyWirelessHardwareEnabled() *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call {
+ return &MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call{Call: _e.mock.On("GetPropertyWirelessHardwareEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyWwanEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyWwanEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyWwanEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyWwanEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWwanEnabled'
+type MockNetworkManager_GetPropertyWwanEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyWwanEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyWwanEnabled() *MockNetworkManager_GetPropertyWwanEnabled_Call {
+ return &MockNetworkManager_GetPropertyWwanEnabled_Call{Call: _e.mock.On("GetPropertyWwanEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyWwanEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWwanEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWwanEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWwanEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWwanEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWwanEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyWwanHardwareEnabled provides a mock function with no fields
+func (_m *MockNetworkManager) GetPropertyWwanHardwareEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyWwanHardwareEnabled")
+ }
+
+ 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
+}
+
+// MockNetworkManager_GetPropertyWwanHardwareEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWwanHardwareEnabled'
+type MockNetworkManager_GetPropertyWwanHardwareEnabled_Call struct {
+ *mock.Call
+}
+
+// GetPropertyWwanHardwareEnabled is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) GetPropertyWwanHardwareEnabled() *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call {
+ return &MockNetworkManager_GetPropertyWwanHardwareEnabled_Call{Call: _e.mock.On("GetPropertyWwanHardwareEnabled")}
+}
+
+func (_c *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// MarshalJSON provides a mock function with no fields
+func (_m *MockNetworkManager) 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
+}
+
+// MockNetworkManager_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON'
+type MockNetworkManager_MarshalJSON_Call struct {
+ *mock.Call
+}
+
+// MarshalJSON is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) MarshalJSON() *MockNetworkManager_MarshalJSON_Call {
+ return &MockNetworkManager_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")}
+}
+
+func (_c *MockNetworkManager_MarshalJSON_Call) Run(run func()) *MockNetworkManager_MarshalJSON_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockNetworkManager_MarshalJSON_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockNetworkManager_MarshalJSON_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Reload provides a mock function with given fields: flags
+func (_m *MockNetworkManager) Reload(flags uint32) error {
+ ret := _m.Called(flags)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Reload")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(uint32) error); ok {
+ r0 = rf(flags)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_Reload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reload'
+type MockNetworkManager_Reload_Call struct {
+ *mock.Call
+}
+
+// Reload is a helper method to define mock.On call
+// - flags uint32
+func (_e *MockNetworkManager_Expecter) Reload(flags interface{}) *MockNetworkManager_Reload_Call {
+ return &MockNetworkManager_Reload_Call{Call: _e.mock.On("Reload", flags)}
+}
+
+func (_c *MockNetworkManager_Reload_Call) Run(run func(flags uint32)) *MockNetworkManager_Reload_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(uint32))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_Reload_Call) Return(_a0 error) *MockNetworkManager_Reload_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_Reload_Call) RunAndReturn(run func(uint32) error) *MockNetworkManager_Reload_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetPropertyWirelessEnabled provides a mock function with given fields: _a0
+func (_m *MockNetworkManager) SetPropertyWirelessEnabled(_a0 bool) error {
+ ret := _m.Called(_a0)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetPropertyWirelessEnabled")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(_a0)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_SetPropertyWirelessEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyWirelessEnabled'
+type MockNetworkManager_SetPropertyWirelessEnabled_Call struct {
+ *mock.Call
+}
+
+// SetPropertyWirelessEnabled is a helper method to define mock.On call
+// - _a0 bool
+func (_e *MockNetworkManager_Expecter) SetPropertyWirelessEnabled(_a0 interface{}) *MockNetworkManager_SetPropertyWirelessEnabled_Call {
+ return &MockNetworkManager_SetPropertyWirelessEnabled_Call{Call: _e.mock.On("SetPropertyWirelessEnabled", _a0)}
+}
+
+func (_c *MockNetworkManager_SetPropertyWirelessEnabled_Call) Run(run func(_a0 bool)) *MockNetworkManager_SetPropertyWirelessEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_SetPropertyWirelessEnabled_Call) Return(_a0 error) *MockNetworkManager_SetPropertyWirelessEnabled_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_SetPropertyWirelessEnabled_Call) RunAndReturn(run func(bool) error) *MockNetworkManager_SetPropertyWirelessEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Sleep provides a mock function with given fields: sleepNWake
+func (_m *MockNetworkManager) Sleep(sleepNWake bool) error {
+ ret := _m.Called(sleepNWake)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Sleep")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(sleepNWake)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockNetworkManager_Sleep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sleep'
+type MockNetworkManager_Sleep_Call struct {
+ *mock.Call
+}
+
+// Sleep is a helper method to define mock.On call
+// - sleepNWake bool
+func (_e *MockNetworkManager_Expecter) Sleep(sleepNWake interface{}) *MockNetworkManager_Sleep_Call {
+ return &MockNetworkManager_Sleep_Call{Call: _e.mock.On("Sleep", sleepNWake)}
+}
+
+func (_c *MockNetworkManager_Sleep_Call) Run(run func(sleepNWake bool)) *MockNetworkManager_Sleep_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_Sleep_Call) Return(_a0 error) *MockNetworkManager_Sleep_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_Sleep_Call) RunAndReturn(run func(bool) error) *MockNetworkManager_Sleep_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// State provides a mock function with no fields
+func (_m *MockNetworkManager) State() (gonetworkmanager.NmState, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for State")
+ }
+
+ var r0 gonetworkmanager.NmState
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmState, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() gonetworkmanager.NmState); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(gonetworkmanager.NmState)
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockNetworkManager_State_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'State'
+type MockNetworkManager_State_Call struct {
+ *mock.Call
+}
+
+// State is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) State() *MockNetworkManager_State_Call {
+ return &MockNetworkManager_State_Call{Call: _e.mock.On("State")}
+}
+
+func (_c *MockNetworkManager_State_Call) Run(run func()) *MockNetworkManager_State_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_State_Call) Return(_a0 gonetworkmanager.NmState, _a1 error) *MockNetworkManager_State_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockNetworkManager_State_Call) RunAndReturn(run func() (gonetworkmanager.NmState, error)) *MockNetworkManager_State_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Subscribe provides a mock function with no fields
+func (_m *MockNetworkManager) Subscribe() <-chan *dbus.Signal {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Subscribe")
+ }
+
+ var r0 <-chan *dbus.Signal
+ if rf, ok := ret.Get(0).(func() <-chan *dbus.Signal); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(<-chan *dbus.Signal)
+ }
+ }
+
+ return r0
+}
+
+// MockNetworkManager_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
+type MockNetworkManager_Subscribe_Call struct {
+ *mock.Call
+}
+
+// Subscribe is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) Subscribe() *MockNetworkManager_Subscribe_Call {
+ return &MockNetworkManager_Subscribe_Call{Call: _e.mock.On("Subscribe")}
+}
+
+func (_c *MockNetworkManager_Subscribe_Call) Run(run func()) *MockNetworkManager_Subscribe_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_Subscribe_Call) Return(_a0 <-chan *dbus.Signal) *MockNetworkManager_Subscribe_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockNetworkManager_Subscribe_Call) RunAndReturn(run func() <-chan *dbus.Signal) *MockNetworkManager_Subscribe_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Unsubscribe provides a mock function with no fields
+func (_m *MockNetworkManager) Unsubscribe() {
+ _m.Called()
+}
+
+// MockNetworkManager_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe'
+type MockNetworkManager_Unsubscribe_Call struct {
+ *mock.Call
+}
+
+// Unsubscribe is a helper method to define mock.On call
+func (_e *MockNetworkManager_Expecter) Unsubscribe() *MockNetworkManager_Unsubscribe_Call {
+ return &MockNetworkManager_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe")}
+}
+
+func (_c *MockNetworkManager_Unsubscribe_Call) Run(run func()) *MockNetworkManager_Unsubscribe_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockNetworkManager_Unsubscribe_Call) Return() *MockNetworkManager_Unsubscribe_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockNetworkManager_Unsubscribe_Call) RunAndReturn(run func()) *MockNetworkManager_Unsubscribe_Call {
+ _c.Run(run)
+ return _c
+}
+
+// NewMockNetworkManager creates a new instance of MockNetworkManager. 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 NewMockNetworkManager(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockNetworkManager {
+ mock := &MockNetworkManager{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Settings.go b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Settings.go
new file mode 100644
index 00000000..20f488ef
--- /dev/null
+++ b/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Settings.go
@@ -0,0 +1,467 @@
+// 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"
+)
+
+// MockSettings is an autogenerated mock type for the Settings type
+type MockSettings struct {
+ mock.Mock
+}
+
+type MockSettings_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockSettings) EXPECT() *MockSettings_Expecter {
+ return &MockSettings_Expecter{mock: &_m.Mock}
+}
+
+// AddConnection provides a mock function with given fields: settings
+func (_m *MockSettings) AddConnection(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) {
+ ret := _m.Called(settings)
+
+ if len(ret) == 0 {
+ panic("no return value specified for AddConnection")
+ }
+
+ var r0 gonetworkmanager.Connection
+ var r1 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)); ok {
+ return rf(settings)
+ }
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) gonetworkmanager.Connection); ok {
+ r0 = rf(settings)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.Connection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(gonetworkmanager.ConnectionSettings) error); ok {
+ r1 = rf(settings)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockSettings_AddConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddConnection'
+type MockSettings_AddConnection_Call struct {
+ *mock.Call
+}
+
+// AddConnection is a helper method to define mock.On call
+// - settings gonetworkmanager.ConnectionSettings
+func (_e *MockSettings_Expecter) AddConnection(settings interface{}) *MockSettings_AddConnection_Call {
+ return &MockSettings_AddConnection_Call{Call: _e.mock.On("AddConnection", settings)}
+}
+
+func (_c *MockSettings_AddConnection_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockSettings_AddConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.ConnectionSettings))
+ })
+ return _c
+}
+
+func (_c *MockSettings_AddConnection_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_AddConnection_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockSettings_AddConnection_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)) *MockSettings_AddConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// AddConnectionUnsaved provides a mock function with given fields: settings
+func (_m *MockSettings) AddConnectionUnsaved(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) {
+ ret := _m.Called(settings)
+
+ if len(ret) == 0 {
+ panic("no return value specified for AddConnectionUnsaved")
+ }
+
+ var r0 gonetworkmanager.Connection
+ var r1 error
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)); ok {
+ return rf(settings)
+ }
+ if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) gonetworkmanager.Connection); ok {
+ r0 = rf(settings)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.Connection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(gonetworkmanager.ConnectionSettings) error); ok {
+ r1 = rf(settings)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockSettings_AddConnectionUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddConnectionUnsaved'
+type MockSettings_AddConnectionUnsaved_Call struct {
+ *mock.Call
+}
+
+// AddConnectionUnsaved is a helper method to define mock.On call
+// - settings gonetworkmanager.ConnectionSettings
+func (_e *MockSettings_Expecter) AddConnectionUnsaved(settings interface{}) *MockSettings_AddConnectionUnsaved_Call {
+ return &MockSettings_AddConnectionUnsaved_Call{Call: _e.mock.On("AddConnectionUnsaved", settings)}
+}
+
+func (_c *MockSettings_AddConnectionUnsaved_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockSettings_AddConnectionUnsaved_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(gonetworkmanager.ConnectionSettings))
+ })
+ return _c
+}
+
+func (_c *MockSettings_AddConnectionUnsaved_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_AddConnectionUnsaved_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockSettings_AddConnectionUnsaved_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)) *MockSettings_AddConnectionUnsaved_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetConnectionByUUID provides a mock function with given fields: uuid
+func (_m *MockSettings) GetConnectionByUUID(uuid string) (gonetworkmanager.Connection, error) {
+ ret := _m.Called(uuid)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetConnectionByUUID")
+ }
+
+ var r0 gonetworkmanager.Connection
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.Connection, error)); ok {
+ return rf(uuid)
+ }
+ if rf, ok := ret.Get(0).(func(string) gonetworkmanager.Connection); ok {
+ r0 = rf(uuid)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(gonetworkmanager.Connection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(uuid)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockSettings_GetConnectionByUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConnectionByUUID'
+type MockSettings_GetConnectionByUUID_Call struct {
+ *mock.Call
+}
+
+// GetConnectionByUUID is a helper method to define mock.On call
+// - uuid string
+func (_e *MockSettings_Expecter) GetConnectionByUUID(uuid interface{}) *MockSettings_GetConnectionByUUID_Call {
+ return &MockSettings_GetConnectionByUUID_Call{Call: _e.mock.On("GetConnectionByUUID", uuid)}
+}
+
+func (_c *MockSettings_GetConnectionByUUID_Call) Run(run func(uuid string)) *MockSettings_GetConnectionByUUID_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockSettings_GetConnectionByUUID_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_GetConnectionByUUID_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockSettings_GetConnectionByUUID_Call) RunAndReturn(run func(string) (gonetworkmanager.Connection, error)) *MockSettings_GetConnectionByUUID_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyCanModify provides a mock function with no fields
+func (_m *MockSettings) GetPropertyCanModify() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyCanModify")
+ }
+
+ 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
+}
+
+// MockSettings_GetPropertyCanModify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyCanModify'
+type MockSettings_GetPropertyCanModify_Call struct {
+ *mock.Call
+}
+
+// GetPropertyCanModify is a helper method to define mock.On call
+func (_e *MockSettings_Expecter) GetPropertyCanModify() *MockSettings_GetPropertyCanModify_Call {
+ return &MockSettings_GetPropertyCanModify_Call{Call: _e.mock.On("GetPropertyCanModify")}
+}
+
+func (_c *MockSettings_GetPropertyCanModify_Call) Run(run func()) *MockSettings_GetPropertyCanModify_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockSettings_GetPropertyCanModify_Call) Return(_a0 bool, _a1 error) *MockSettings_GetPropertyCanModify_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockSettings_GetPropertyCanModify_Call) RunAndReturn(run func() (bool, error)) *MockSettings_GetPropertyCanModify_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPropertyHostname provides a mock function with no fields
+func (_m *MockSettings) GetPropertyHostname() (string, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPropertyHostname")
+ }
+
+ 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
+}
+
+// MockSettings_GetPropertyHostname_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHostname'
+type MockSettings_GetPropertyHostname_Call struct {
+ *mock.Call
+}
+
+// GetPropertyHostname is a helper method to define mock.On call
+func (_e *MockSettings_Expecter) GetPropertyHostname() *MockSettings_GetPropertyHostname_Call {
+ return &MockSettings_GetPropertyHostname_Call{Call: _e.mock.On("GetPropertyHostname")}
+}
+
+func (_c *MockSettings_GetPropertyHostname_Call) Run(run func()) *MockSettings_GetPropertyHostname_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockSettings_GetPropertyHostname_Call) Return(_a0 string, _a1 error) *MockSettings_GetPropertyHostname_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockSettings_GetPropertyHostname_Call) RunAndReturn(run func() (string, error)) *MockSettings_GetPropertyHostname_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ListConnections provides a mock function with no fields
+func (_m *MockSettings) ListConnections() ([]gonetworkmanager.Connection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ListConnections")
+ }
+
+ var r0 []gonetworkmanager.Connection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Connection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []gonetworkmanager.Connection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]gonetworkmanager.Connection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockSettings_ListConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListConnections'
+type MockSettings_ListConnections_Call struct {
+ *mock.Call
+}
+
+// ListConnections is a helper method to define mock.On call
+func (_e *MockSettings_Expecter) ListConnections() *MockSettings_ListConnections_Call {
+ return &MockSettings_ListConnections_Call{Call: _e.mock.On("ListConnections")}
+}
+
+func (_c *MockSettings_ListConnections_Call) Run(run func()) *MockSettings_ListConnections_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockSettings_ListConnections_Call) Return(_a0 []gonetworkmanager.Connection, _a1 error) *MockSettings_ListConnections_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockSettings_ListConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.Connection, error)) *MockSettings_ListConnections_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ReloadConnections provides a mock function with no fields
+func (_m *MockSettings) ReloadConnections() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ReloadConnections")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockSettings_ReloadConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReloadConnections'
+type MockSettings_ReloadConnections_Call struct {
+ *mock.Call
+}
+
+// ReloadConnections is a helper method to define mock.On call
+func (_e *MockSettings_Expecter) ReloadConnections() *MockSettings_ReloadConnections_Call {
+ return &MockSettings_ReloadConnections_Call{Call: _e.mock.On("ReloadConnections")}
+}
+
+func (_c *MockSettings_ReloadConnections_Call) Run(run func()) *MockSettings_ReloadConnections_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockSettings_ReloadConnections_Call) Return(_a0 error) *MockSettings_ReloadConnections_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockSettings_ReloadConnections_Call) RunAndReturn(run func() error) *MockSettings_ReloadConnections_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SaveHostname provides a mock function with given fields: hostname
+func (_m *MockSettings) SaveHostname(hostname string) error {
+ ret := _m.Called(hostname)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SaveHostname")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(hostname)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockSettings_SaveHostname_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveHostname'
+type MockSettings_SaveHostname_Call struct {
+ *mock.Call
+}
+
+// SaveHostname is a helper method to define mock.On call
+// - hostname string
+func (_e *MockSettings_Expecter) SaveHostname(hostname interface{}) *MockSettings_SaveHostname_Call {
+ return &MockSettings_SaveHostname_Call{Call: _e.mock.On("SaveHostname", hostname)}
+}
+
+func (_c *MockSettings_SaveHostname_Call) Run(run func(hostname string)) *MockSettings_SaveHostname_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockSettings_SaveHostname_Call) Return(_a0 error) *MockSettings_SaveHostname_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockSettings_SaveHostname_Call) RunAndReturn(run func(string) error) *MockSettings_SaveHostname_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockSettings creates a new instance of MockSettings. 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 NewMockSettings(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockSettings {
+ mock := &MockSettings{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/github.com/godbus/dbus/v5/mock_BusObject.go b/backend/internal/mocks/github.com/godbus/dbus/v5/mock_BusObject.go
new file mode 100644
index 00000000..c0d1a3fa
--- /dev/null
+++ b/backend/internal/mocks/github.com/godbus/dbus/v5/mock_BusObject.go
@@ -0,0 +1,649 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package dbus
+
+import (
+ context "context"
+
+ dbus "github.com/godbus/dbus/v5"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockBusObject is an autogenerated mock type for the BusObject type
+type MockBusObject struct {
+ mock.Mock
+}
+
+type MockBusObject_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockBusObject) EXPECT() *MockBusObject_Expecter {
+ return &MockBusObject_Expecter{mock: &_m.Mock}
+}
+
+// AddMatchSignal provides a mock function with given fields: iface, member, options
+func (_m *MockBusObject) AddMatchSignal(iface string, member string, options ...dbus.MatchOption) *dbus.Call {
+ _va := make([]interface{}, len(options))
+ for _i := range options {
+ _va[_i] = options[_i]
+ }
+ var _ca []interface{}
+ _ca = append(_ca, iface, member)
+ _ca = append(_ca, _va...)
+ ret := _m.Called(_ca...)
+
+ if len(ret) == 0 {
+ panic("no return value specified for AddMatchSignal")
+ }
+
+ var r0 *dbus.Call
+ if rf, ok := ret.Get(0).(func(string, string, ...dbus.MatchOption) *dbus.Call); ok {
+ r0 = rf(iface, member, options...)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*dbus.Call)
+ }
+ }
+
+ return r0
+}
+
+// MockBusObject_AddMatchSignal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddMatchSignal'
+type MockBusObject_AddMatchSignal_Call struct {
+ *mock.Call
+}
+
+// AddMatchSignal is a helper method to define mock.On call
+// - iface string
+// - member string
+// - options ...dbus.MatchOption
+func (_e *MockBusObject_Expecter) AddMatchSignal(iface interface{}, member interface{}, options ...interface{}) *MockBusObject_AddMatchSignal_Call {
+ return &MockBusObject_AddMatchSignal_Call{Call: _e.mock.On("AddMatchSignal",
+ append([]interface{}{iface, member}, options...)...)}
+}
+
+func (_c *MockBusObject_AddMatchSignal_Call) Run(run func(iface string, member string, options ...dbus.MatchOption)) *MockBusObject_AddMatchSignal_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ variadicArgs := make([]dbus.MatchOption, len(args)-2)
+ for i, a := range args[2:] {
+ if a != nil {
+ variadicArgs[i] = a.(dbus.MatchOption)
+ }
+ }
+ run(args[0].(string), args[1].(string), variadicArgs...)
+ })
+ return _c
+}
+
+func (_c *MockBusObject_AddMatchSignal_Call) Return(_a0 *dbus.Call) *MockBusObject_AddMatchSignal_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_AddMatchSignal_Call) RunAndReturn(run func(string, string, ...dbus.MatchOption) *dbus.Call) *MockBusObject_AddMatchSignal_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Call provides a mock function with given fields: method, flags, args
+func (_m *MockBusObject) Call(method string, flags dbus.Flags, args ...interface{}) *dbus.Call {
+ var _ca []interface{}
+ _ca = append(_ca, method, flags)
+ _ca = append(_ca, args...)
+ ret := _m.Called(_ca...)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Call")
+ }
+
+ var r0 *dbus.Call
+ if rf, ok := ret.Get(0).(func(string, dbus.Flags, ...interface{}) *dbus.Call); ok {
+ r0 = rf(method, flags, args...)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*dbus.Call)
+ }
+ }
+
+ return r0
+}
+
+// MockBusObject_Call_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Call'
+type MockBusObject_Call_Call struct {
+ *mock.Call
+}
+
+// Call is a helper method to define mock.On call
+// - method string
+// - flags dbus.Flags
+// - args ...interface{}
+func (_e *MockBusObject_Expecter) Call(method interface{}, flags interface{}, args ...interface{}) *MockBusObject_Call_Call {
+ return &MockBusObject_Call_Call{Call: _e.mock.On("Call",
+ append([]interface{}{method, flags}, args...)...)}
+}
+
+func (_c *MockBusObject_Call_Call) Run(run func(method string, flags dbus.Flags, args ...interface{})) *MockBusObject_Call_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ variadicArgs := make([]interface{}, len(args)-2)
+ for i, a := range args[2:] {
+ if a != nil {
+ variadicArgs[i] = a.(interface{})
+ }
+ }
+ run(args[0].(string), args[1].(dbus.Flags), variadicArgs...)
+ })
+ return _c
+}
+
+func (_c *MockBusObject_Call_Call) Return(_a0 *dbus.Call) *MockBusObject_Call_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_Call_Call) RunAndReturn(run func(string, dbus.Flags, ...interface{}) *dbus.Call) *MockBusObject_Call_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// CallWithContext provides a mock function with given fields: ctx, method, flags, args
+func (_m *MockBusObject) CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...interface{}) *dbus.Call {
+ var _ca []interface{}
+ _ca = append(_ca, ctx, method, flags)
+ _ca = append(_ca, args...)
+ ret := _m.Called(_ca...)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CallWithContext")
+ }
+
+ var r0 *dbus.Call
+ if rf, ok := ret.Get(0).(func(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call); ok {
+ r0 = rf(ctx, method, flags, args...)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*dbus.Call)
+ }
+ }
+
+ return r0
+}
+
+// MockBusObject_CallWithContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CallWithContext'
+type MockBusObject_CallWithContext_Call struct {
+ *mock.Call
+}
+
+// CallWithContext is a helper method to define mock.On call
+// - ctx context.Context
+// - method string
+// - flags dbus.Flags
+// - args ...interface{}
+func (_e *MockBusObject_Expecter) CallWithContext(ctx interface{}, method interface{}, flags interface{}, args ...interface{}) *MockBusObject_CallWithContext_Call {
+ return &MockBusObject_CallWithContext_Call{Call: _e.mock.On("CallWithContext",
+ append([]interface{}{ctx, method, flags}, args...)...)}
+}
+
+func (_c *MockBusObject_CallWithContext_Call) Run(run func(ctx context.Context, method string, flags dbus.Flags, args ...interface{})) *MockBusObject_CallWithContext_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ variadicArgs := make([]interface{}, len(args)-3)
+ for i, a := range args[3:] {
+ if a != nil {
+ variadicArgs[i] = a.(interface{})
+ }
+ }
+ run(args[0].(context.Context), args[1].(string), args[2].(dbus.Flags), variadicArgs...)
+ })
+ return _c
+}
+
+func (_c *MockBusObject_CallWithContext_Call) Return(_a0 *dbus.Call) *MockBusObject_CallWithContext_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_CallWithContext_Call) RunAndReturn(run func(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call) *MockBusObject_CallWithContext_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Destination provides a mock function with no fields
+func (_m *MockBusObject) Destination() string {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Destination")
+ }
+
+ var r0 string
+ if rf, ok := ret.Get(0).(func() string); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(string)
+ }
+
+ return r0
+}
+
+// MockBusObject_Destination_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Destination'
+type MockBusObject_Destination_Call struct {
+ *mock.Call
+}
+
+// Destination is a helper method to define mock.On call
+func (_e *MockBusObject_Expecter) Destination() *MockBusObject_Destination_Call {
+ return &MockBusObject_Destination_Call{Call: _e.mock.On("Destination")}
+}
+
+func (_c *MockBusObject_Destination_Call) Run(run func()) *MockBusObject_Destination_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBusObject_Destination_Call) Return(_a0 string) *MockBusObject_Destination_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_Destination_Call) RunAndReturn(run func() string) *MockBusObject_Destination_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetProperty provides a mock function with given fields: p
+func (_m *MockBusObject) GetProperty(p string) (dbus.Variant, error) {
+ ret := _m.Called(p)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetProperty")
+ }
+
+ var r0 dbus.Variant
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (dbus.Variant, error)); ok {
+ return rf(p)
+ }
+ if rf, ok := ret.Get(0).(func(string) dbus.Variant); ok {
+ r0 = rf(p)
+ } else {
+ r0 = ret.Get(0).(dbus.Variant)
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(p)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockBusObject_GetProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProperty'
+type MockBusObject_GetProperty_Call struct {
+ *mock.Call
+}
+
+// GetProperty is a helper method to define mock.On call
+// - p string
+func (_e *MockBusObject_Expecter) GetProperty(p interface{}) *MockBusObject_GetProperty_Call {
+ return &MockBusObject_GetProperty_Call{Call: _e.mock.On("GetProperty", p)}
+}
+
+func (_c *MockBusObject_GetProperty_Call) Run(run func(p string)) *MockBusObject_GetProperty_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBusObject_GetProperty_Call) Return(_a0 dbus.Variant, _a1 error) *MockBusObject_GetProperty_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBusObject_GetProperty_Call) RunAndReturn(run func(string) (dbus.Variant, error)) *MockBusObject_GetProperty_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Go provides a mock function with given fields: method, flags, ch, args
+func (_m *MockBusObject) Go(method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{}) *dbus.Call {
+ var _ca []interface{}
+ _ca = append(_ca, method, flags, ch)
+ _ca = append(_ca, args...)
+ ret := _m.Called(_ca...)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Go")
+ }
+
+ var r0 *dbus.Call
+ if rf, ok := ret.Get(0).(func(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call); ok {
+ r0 = rf(method, flags, ch, args...)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*dbus.Call)
+ }
+ }
+
+ return r0
+}
+
+// MockBusObject_Go_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Go'
+type MockBusObject_Go_Call struct {
+ *mock.Call
+}
+
+// Go is a helper method to define mock.On call
+// - method string
+// - flags dbus.Flags
+// - ch chan *dbus.Call
+// - args ...interface{}
+func (_e *MockBusObject_Expecter) Go(method interface{}, flags interface{}, ch interface{}, args ...interface{}) *MockBusObject_Go_Call {
+ return &MockBusObject_Go_Call{Call: _e.mock.On("Go",
+ append([]interface{}{method, flags, ch}, args...)...)}
+}
+
+func (_c *MockBusObject_Go_Call) Run(run func(method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{})) *MockBusObject_Go_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ variadicArgs := make([]interface{}, len(args)-3)
+ for i, a := range args[3:] {
+ if a != nil {
+ variadicArgs[i] = a.(interface{})
+ }
+ }
+ run(args[0].(string), args[1].(dbus.Flags), args[2].(chan *dbus.Call), variadicArgs...)
+ })
+ return _c
+}
+
+func (_c *MockBusObject_Go_Call) Return(_a0 *dbus.Call) *MockBusObject_Go_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_Go_Call) RunAndReturn(run func(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call) *MockBusObject_Go_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GoWithContext provides a mock function with given fields: ctx, method, flags, ch, args
+func (_m *MockBusObject) GoWithContext(ctx context.Context, method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{}) *dbus.Call {
+ var _ca []interface{}
+ _ca = append(_ca, ctx, method, flags, ch)
+ _ca = append(_ca, args...)
+ ret := _m.Called(_ca...)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GoWithContext")
+ }
+
+ var r0 *dbus.Call
+ if rf, ok := ret.Get(0).(func(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call); ok {
+ r0 = rf(ctx, method, flags, ch, args...)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*dbus.Call)
+ }
+ }
+
+ return r0
+}
+
+// MockBusObject_GoWithContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GoWithContext'
+type MockBusObject_GoWithContext_Call struct {
+ *mock.Call
+}
+
+// GoWithContext is a helper method to define mock.On call
+// - ctx context.Context
+// - method string
+// - flags dbus.Flags
+// - ch chan *dbus.Call
+// - args ...interface{}
+func (_e *MockBusObject_Expecter) GoWithContext(ctx interface{}, method interface{}, flags interface{}, ch interface{}, args ...interface{}) *MockBusObject_GoWithContext_Call {
+ return &MockBusObject_GoWithContext_Call{Call: _e.mock.On("GoWithContext",
+ append([]interface{}{ctx, method, flags, ch}, args...)...)}
+}
+
+func (_c *MockBusObject_GoWithContext_Call) Run(run func(ctx context.Context, method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{})) *MockBusObject_GoWithContext_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ variadicArgs := make([]interface{}, len(args)-4)
+ for i, a := range args[4:] {
+ if a != nil {
+ variadicArgs[i] = a.(interface{})
+ }
+ }
+ run(args[0].(context.Context), args[1].(string), args[2].(dbus.Flags), args[3].(chan *dbus.Call), variadicArgs...)
+ })
+ return _c
+}
+
+func (_c *MockBusObject_GoWithContext_Call) Return(_a0 *dbus.Call) *MockBusObject_GoWithContext_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_GoWithContext_Call) RunAndReturn(run func(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call) *MockBusObject_GoWithContext_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Path provides a mock function with no fields
+func (_m *MockBusObject) Path() dbus.ObjectPath {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Path")
+ }
+
+ 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
+}
+
+// MockBusObject_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path'
+type MockBusObject_Path_Call struct {
+ *mock.Call
+}
+
+// Path is a helper method to define mock.On call
+func (_e *MockBusObject_Expecter) Path() *MockBusObject_Path_Call {
+ return &MockBusObject_Path_Call{Call: _e.mock.On("Path")}
+}
+
+func (_c *MockBusObject_Path_Call) Run(run func()) *MockBusObject_Path_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBusObject_Path_Call) Return(_a0 dbus.ObjectPath) *MockBusObject_Path_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_Path_Call) RunAndReturn(run func() dbus.ObjectPath) *MockBusObject_Path_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// RemoveMatchSignal provides a mock function with given fields: iface, member, options
+func (_m *MockBusObject) RemoveMatchSignal(iface string, member string, options ...dbus.MatchOption) *dbus.Call {
+ _va := make([]interface{}, len(options))
+ for _i := range options {
+ _va[_i] = options[_i]
+ }
+ var _ca []interface{}
+ _ca = append(_ca, iface, member)
+ _ca = append(_ca, _va...)
+ ret := _m.Called(_ca...)
+
+ if len(ret) == 0 {
+ panic("no return value specified for RemoveMatchSignal")
+ }
+
+ var r0 *dbus.Call
+ if rf, ok := ret.Get(0).(func(string, string, ...dbus.MatchOption) *dbus.Call); ok {
+ r0 = rf(iface, member, options...)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*dbus.Call)
+ }
+ }
+
+ return r0
+}
+
+// MockBusObject_RemoveMatchSignal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveMatchSignal'
+type MockBusObject_RemoveMatchSignal_Call struct {
+ *mock.Call
+}
+
+// RemoveMatchSignal is a helper method to define mock.On call
+// - iface string
+// - member string
+// - options ...dbus.MatchOption
+func (_e *MockBusObject_Expecter) RemoveMatchSignal(iface interface{}, member interface{}, options ...interface{}) *MockBusObject_RemoveMatchSignal_Call {
+ return &MockBusObject_RemoveMatchSignal_Call{Call: _e.mock.On("RemoveMatchSignal",
+ append([]interface{}{iface, member}, options...)...)}
+}
+
+func (_c *MockBusObject_RemoveMatchSignal_Call) Run(run func(iface string, member string, options ...dbus.MatchOption)) *MockBusObject_RemoveMatchSignal_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ variadicArgs := make([]dbus.MatchOption, len(args)-2)
+ for i, a := range args[2:] {
+ if a != nil {
+ variadicArgs[i] = a.(dbus.MatchOption)
+ }
+ }
+ run(args[0].(string), args[1].(string), variadicArgs...)
+ })
+ return _c
+}
+
+func (_c *MockBusObject_RemoveMatchSignal_Call) Return(_a0 *dbus.Call) *MockBusObject_RemoveMatchSignal_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_RemoveMatchSignal_Call) RunAndReturn(run func(string, string, ...dbus.MatchOption) *dbus.Call) *MockBusObject_RemoveMatchSignal_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetProperty provides a mock function with given fields: p, v
+func (_m *MockBusObject) SetProperty(p string, v interface{}) error {
+ ret := _m.Called(p, v)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetProperty")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
+ r0 = rf(p, v)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBusObject_SetProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetProperty'
+type MockBusObject_SetProperty_Call struct {
+ *mock.Call
+}
+
+// SetProperty is a helper method to define mock.On call
+// - p string
+// - v interface{}
+func (_e *MockBusObject_Expecter) SetProperty(p interface{}, v interface{}) *MockBusObject_SetProperty_Call {
+ return &MockBusObject_SetProperty_Call{Call: _e.mock.On("SetProperty", p, v)}
+}
+
+func (_c *MockBusObject_SetProperty_Call) Run(run func(p string, v interface{})) *MockBusObject_SetProperty_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(interface{}))
+ })
+ return _c
+}
+
+func (_c *MockBusObject_SetProperty_Call) Return(_a0 error) *MockBusObject_SetProperty_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_SetProperty_Call) RunAndReturn(run func(string, interface{}) error) *MockBusObject_SetProperty_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// StoreProperty provides a mock function with given fields: p, value
+func (_m *MockBusObject) StoreProperty(p string, value interface{}) error {
+ ret := _m.Called(p, value)
+
+ if len(ret) == 0 {
+ panic("no return value specified for StoreProperty")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
+ r0 = rf(p, value)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBusObject_StoreProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StoreProperty'
+type MockBusObject_StoreProperty_Call struct {
+ *mock.Call
+}
+
+// StoreProperty is a helper method to define mock.On call
+// - p string
+// - value interface{}
+func (_e *MockBusObject_Expecter) StoreProperty(p interface{}, value interface{}) *MockBusObject_StoreProperty_Call {
+ return &MockBusObject_StoreProperty_Call{Call: _e.mock.On("StoreProperty", p, value)}
+}
+
+func (_c *MockBusObject_StoreProperty_Call) Run(run func(p string, value interface{})) *MockBusObject_StoreProperty_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(interface{}))
+ })
+ return _c
+}
+
+func (_c *MockBusObject_StoreProperty_Call) Return(_a0 error) *MockBusObject_StoreProperty_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBusObject_StoreProperty_Call) RunAndReturn(run func(string, interface{}) error) *MockBusObject_StoreProperty_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockBusObject creates a new instance of MockBusObject. 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 NewMockBusObject(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockBusObject {
+ mock := &MockBusObject{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/internal/plugins/mock_GitClient.go b/backend/internal/mocks/internal/plugins/mock_GitClient.go
new file mode 100644
index 00000000..d43afed0
--- /dev/null
+++ b/backend/internal/mocks/internal/plugins/mock_GitClient.go
@@ -0,0 +1,181 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package plugins
+
+import mock "github.com/stretchr/testify/mock"
+
+// MockGitClient is an autogenerated mock type for the GitClient type
+type MockGitClient struct {
+ mock.Mock
+}
+
+type MockGitClient_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockGitClient) EXPECT() *MockGitClient_Expecter {
+ return &MockGitClient_Expecter{mock: &_m.Mock}
+}
+
+// HasUpdates provides a mock function with given fields: path
+func (_m *MockGitClient) HasUpdates(path string) (bool, error) {
+ ret := _m.Called(path)
+
+ if len(ret) == 0 {
+ panic("no return value specified for HasUpdates")
+ }
+
+ var r0 bool
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (bool, error)); ok {
+ return rf(path)
+ }
+ if rf, ok := ret.Get(0).(func(string) bool); ok {
+ r0 = rf(path)
+ } else {
+ r0 = ret.Get(0).(bool)
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(path)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockGitClient_HasUpdates_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasUpdates'
+type MockGitClient_HasUpdates_Call struct {
+ *mock.Call
+}
+
+// HasUpdates is a helper method to define mock.On call
+// - path string
+func (_e *MockGitClient_Expecter) HasUpdates(path interface{}) *MockGitClient_HasUpdates_Call {
+ return &MockGitClient_HasUpdates_Call{Call: _e.mock.On("HasUpdates", path)}
+}
+
+func (_c *MockGitClient_HasUpdates_Call) Run(run func(path string)) *MockGitClient_HasUpdates_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockGitClient_HasUpdates_Call) Return(_a0 bool, _a1 error) *MockGitClient_HasUpdates_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockGitClient_HasUpdates_Call) RunAndReturn(run func(string) (bool, error)) *MockGitClient_HasUpdates_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// PlainClone provides a mock function with given fields: path, url
+func (_m *MockGitClient) PlainClone(path string, url string) error {
+ ret := _m.Called(path, url)
+
+ if len(ret) == 0 {
+ panic("no return value specified for PlainClone")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, string) error); ok {
+ r0 = rf(path, url)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockGitClient_PlainClone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PlainClone'
+type MockGitClient_PlainClone_Call struct {
+ *mock.Call
+}
+
+// PlainClone is a helper method to define mock.On call
+// - path string
+// - url string
+func (_e *MockGitClient_Expecter) PlainClone(path interface{}, url interface{}) *MockGitClient_PlainClone_Call {
+ return &MockGitClient_PlainClone_Call{Call: _e.mock.On("PlainClone", path, url)}
+}
+
+func (_c *MockGitClient_PlainClone_Call) Run(run func(path string, url string)) *MockGitClient_PlainClone_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(string))
+ })
+ return _c
+}
+
+func (_c *MockGitClient_PlainClone_Call) Return(_a0 error) *MockGitClient_PlainClone_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockGitClient_PlainClone_Call) RunAndReturn(run func(string, string) error) *MockGitClient_PlainClone_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Pull provides a mock function with given fields: path
+func (_m *MockGitClient) Pull(path string) error {
+ ret := _m.Called(path)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Pull")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(path)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockGitClient_Pull_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pull'
+type MockGitClient_Pull_Call struct {
+ *mock.Call
+}
+
+// Pull is a helper method to define mock.On call
+// - path string
+func (_e *MockGitClient_Expecter) Pull(path interface{}) *MockGitClient_Pull_Call {
+ return &MockGitClient_Pull_Call{Call: _e.mock.On("Pull", path)}
+}
+
+func (_c *MockGitClient_Pull_Call) Run(run func(path string)) *MockGitClient_Pull_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockGitClient_Pull_Call) Return(_a0 error) *MockGitClient_Pull_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockGitClient_Pull_Call) RunAndReturn(run func(string) error) *MockGitClient_Pull_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockGitClient creates a new instance of MockGitClient. 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 NewMockGitClient(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockGitClient {
+ mock := &MockGitClient{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/net/mock_Conn.go b/backend/internal/mocks/net/mock_Conn.go
new file mode 100644
index 00000000..248a0d8a
--- /dev/null
+++ b/backend/internal/mocks/net/mock_Conn.go
@@ -0,0 +1,427 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package net
+
+import (
+ net "net"
+
+ mock "github.com/stretchr/testify/mock"
+
+ time "time"
+)
+
+// MockConn is an autogenerated mock type for the Conn type
+type MockConn struct {
+ mock.Mock
+}
+
+type MockConn_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockConn) EXPECT() *MockConn_Expecter {
+ return &MockConn_Expecter{mock: &_m.Mock}
+}
+
+// Close provides a mock function with no fields
+func (_m *MockConn) 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
+}
+
+// MockConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
+type MockConn_Close_Call struct {
+ *mock.Call
+}
+
+// Close is a helper method to define mock.On call
+func (_e *MockConn_Expecter) Close() *MockConn_Close_Call {
+ return &MockConn_Close_Call{Call: _e.mock.On("Close")}
+}
+
+func (_c *MockConn_Close_Call) Run(run func()) *MockConn_Close_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockConn_Close_Call) Return(_a0 error) *MockConn_Close_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_Close_Call) RunAndReturn(run func() error) *MockConn_Close_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// LocalAddr provides a mock function with no fields
+func (_m *MockConn) LocalAddr() net.Addr {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for LocalAddr")
+ }
+
+ var r0 net.Addr
+ if rf, ok := ret.Get(0).(func() net.Addr); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(net.Addr)
+ }
+ }
+
+ return r0
+}
+
+// MockConn_LocalAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LocalAddr'
+type MockConn_LocalAddr_Call struct {
+ *mock.Call
+}
+
+// LocalAddr is a helper method to define mock.On call
+func (_e *MockConn_Expecter) LocalAddr() *MockConn_LocalAddr_Call {
+ return &MockConn_LocalAddr_Call{Call: _e.mock.On("LocalAddr")}
+}
+
+func (_c *MockConn_LocalAddr_Call) Run(run func()) *MockConn_LocalAddr_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockConn_LocalAddr_Call) Return(_a0 net.Addr) *MockConn_LocalAddr_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_LocalAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_LocalAddr_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Read provides a mock function with given fields: b
+func (_m *MockConn) Read(b []byte) (int, error) {
+ ret := _m.Called(b)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Read")
+ }
+
+ var r0 int
+ var r1 error
+ if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok {
+ return rf(b)
+ }
+ if rf, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = rf(b)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte) error); ok {
+ r1 = rf(b)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockConn_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
+type MockConn_Read_Call struct {
+ *mock.Call
+}
+
+// Read is a helper method to define mock.On call
+// - b []byte
+func (_e *MockConn_Expecter) Read(b interface{}) *MockConn_Read_Call {
+ return &MockConn_Read_Call{Call: _e.mock.On("Read", b)}
+}
+
+func (_c *MockConn_Read_Call) Run(run func(b []byte)) *MockConn_Read_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte))
+ })
+ return _c
+}
+
+func (_c *MockConn_Read_Call) Return(n int, err error) *MockConn_Read_Call {
+ _c.Call.Return(n, err)
+ return _c
+}
+
+func (_c *MockConn_Read_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Read_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// RemoteAddr provides a mock function with no fields
+func (_m *MockConn) RemoteAddr() net.Addr {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for RemoteAddr")
+ }
+
+ var r0 net.Addr
+ if rf, ok := ret.Get(0).(func() net.Addr); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(net.Addr)
+ }
+ }
+
+ return r0
+}
+
+// MockConn_RemoteAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoteAddr'
+type MockConn_RemoteAddr_Call struct {
+ *mock.Call
+}
+
+// RemoteAddr is a helper method to define mock.On call
+func (_e *MockConn_Expecter) RemoteAddr() *MockConn_RemoteAddr_Call {
+ return &MockConn_RemoteAddr_Call{Call: _e.mock.On("RemoteAddr")}
+}
+
+func (_c *MockConn_RemoteAddr_Call) Run(run func()) *MockConn_RemoteAddr_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockConn_RemoteAddr_Call) Return(_a0 net.Addr) *MockConn_RemoteAddr_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_RemoteAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_RemoteAddr_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetDeadline provides a mock function with given fields: t
+func (_m *MockConn) SetDeadline(t time.Time) error {
+ ret := _m.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetDeadline")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = rf(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockConn_SetDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDeadline'
+type MockConn_SetDeadline_Call struct {
+ *mock.Call
+}
+
+// SetDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockConn_Expecter) SetDeadline(t interface{}) *MockConn_SetDeadline_Call {
+ return &MockConn_SetDeadline_Call{Call: _e.mock.On("SetDeadline", t)}
+}
+
+func (_c *MockConn_SetDeadline_Call) Run(run func(t time.Time)) *MockConn_SetDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(time.Time))
+ })
+ return _c
+}
+
+func (_c *MockConn_SetDeadline_Call) Return(_a0 error) *MockConn_SetDeadline_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_SetDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetReadDeadline provides a mock function with given fields: t
+func (_m *MockConn) SetReadDeadline(t time.Time) error {
+ ret := _m.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetReadDeadline")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = rf(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockConn_SetReadDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetReadDeadline'
+type MockConn_SetReadDeadline_Call struct {
+ *mock.Call
+}
+
+// SetReadDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockConn_Expecter) SetReadDeadline(t interface{}) *MockConn_SetReadDeadline_Call {
+ return &MockConn_SetReadDeadline_Call{Call: _e.mock.On("SetReadDeadline", t)}
+}
+
+func (_c *MockConn_SetReadDeadline_Call) Run(run func(t time.Time)) *MockConn_SetReadDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(time.Time))
+ })
+ return _c
+}
+
+func (_c *MockConn_SetReadDeadline_Call) Return(_a0 error) *MockConn_SetReadDeadline_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_SetReadDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetReadDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetWriteDeadline provides a mock function with given fields: t
+func (_m *MockConn) SetWriteDeadline(t time.Time) error {
+ ret := _m.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetWriteDeadline")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = rf(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockConn_SetWriteDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWriteDeadline'
+type MockConn_SetWriteDeadline_Call struct {
+ *mock.Call
+}
+
+// SetWriteDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockConn_Expecter) SetWriteDeadline(t interface{}) *MockConn_SetWriteDeadline_Call {
+ return &MockConn_SetWriteDeadline_Call{Call: _e.mock.On("SetWriteDeadline", t)}
+}
+
+func (_c *MockConn_SetWriteDeadline_Call) Run(run func(t time.Time)) *MockConn_SetWriteDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(time.Time))
+ })
+ return _c
+}
+
+func (_c *MockConn_SetWriteDeadline_Call) Return(_a0 error) *MockConn_SetWriteDeadline_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockConn_SetWriteDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetWriteDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Write provides a mock function with given fields: b
+func (_m *MockConn) Write(b []byte) (int, error) {
+ ret := _m.Called(b)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Write")
+ }
+
+ var r0 int
+ var r1 error
+ if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok {
+ return rf(b)
+ }
+ if rf, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = rf(b)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+
+ if rf, ok := ret.Get(1).(func([]byte) error); ok {
+ r1 = rf(b)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockConn_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
+type MockConn_Write_Call struct {
+ *mock.Call
+}
+
+// Write is a helper method to define mock.On call
+// - b []byte
+func (_e *MockConn_Expecter) Write(b interface{}) *MockConn_Write_Call {
+ return &MockConn_Write_Call{Call: _e.mock.On("Write", b)}
+}
+
+func (_c *MockConn_Write_Call) Run(run func(b []byte)) *MockConn_Write_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].([]byte))
+ })
+ return _c
+}
+
+func (_c *MockConn_Write_Call) Return(n int, err error) *MockConn_Write_Call {
+ _c.Call.Return(n, err)
+ return _c
+}
+
+func (_c *MockConn_Write_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Write_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockConn creates a new instance of MockConn. 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 NewMockConn(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockConn {
+ mock := &MockConn{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/mocks/network/mock_Backend.go b/backend/internal/mocks/network/mock_Backend.go
new file mode 100644
index 00000000..fcb1a428
--- /dev/null
+++ b/backend/internal/mocks/network/mock_Backend.go
@@ -0,0 +1,1371 @@
+// Code generated by mockery v2.53.5. DO NOT EDIT.
+
+package mocks_network
+
+import (
+ network "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/network"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockBackend is an autogenerated mock type for the Backend type
+type MockBackend struct {
+ mock.Mock
+}
+
+type MockBackend_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockBackend) EXPECT() *MockBackend_Expecter {
+ return &MockBackend_Expecter{mock: &_m.Mock}
+}
+
+// ActivateWiredConnection provides a mock function with given fields: uuid
+func (_m *MockBackend) ActivateWiredConnection(uuid string) error {
+ ret := _m.Called(uuid)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ActivateWiredConnection")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(uuid)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_ActivateWiredConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ActivateWiredConnection'
+type MockBackend_ActivateWiredConnection_Call struct {
+ *mock.Call
+}
+
+// ActivateWiredConnection is a helper method to define mock.On call
+// - uuid string
+func (_e *MockBackend_Expecter) ActivateWiredConnection(uuid interface{}) *MockBackend_ActivateWiredConnection_Call {
+ return &MockBackend_ActivateWiredConnection_Call{Call: _e.mock.On("ActivateWiredConnection", uuid)}
+}
+
+func (_c *MockBackend_ActivateWiredConnection_Call) Run(run func(uuid string)) *MockBackend_ActivateWiredConnection_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBackend_ActivateWiredConnection_Call) Return(_a0 error) *MockBackend_ActivateWiredConnection_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_ActivateWiredConnection_Call) RunAndReturn(run func(string) error) *MockBackend_ActivateWiredConnection_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// CancelCredentials provides a mock function with given fields: token
+func (_m *MockBackend) CancelCredentials(token string) error {
+ ret := _m.Called(token)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CancelCredentials")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(token)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_CancelCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelCredentials'
+type MockBackend_CancelCredentials_Call struct {
+ *mock.Call
+}
+
+// CancelCredentials is a helper method to define mock.On call
+// - token string
+func (_e *MockBackend_Expecter) CancelCredentials(token interface{}) *MockBackend_CancelCredentials_Call {
+ return &MockBackend_CancelCredentials_Call{Call: _e.mock.On("CancelCredentials", token)}
+}
+
+func (_c *MockBackend_CancelCredentials_Call) Run(run func(token string)) *MockBackend_CancelCredentials_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBackend_CancelCredentials_Call) Return(_a0 error) *MockBackend_CancelCredentials_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_CancelCredentials_Call) RunAndReturn(run func(string) error) *MockBackend_CancelCredentials_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ClearVPNCredentials provides a mock function with given fields: uuidOrName
+func (_m *MockBackend) ClearVPNCredentials(uuidOrName string) error {
+ ret := _m.Called(uuidOrName)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ClearVPNCredentials")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(uuidOrName)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_ClearVPNCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearVPNCredentials'
+type MockBackend_ClearVPNCredentials_Call struct {
+ *mock.Call
+}
+
+// ClearVPNCredentials is a helper method to define mock.On call
+// - uuidOrName string
+func (_e *MockBackend_Expecter) ClearVPNCredentials(uuidOrName interface{}) *MockBackend_ClearVPNCredentials_Call {
+ return &MockBackend_ClearVPNCredentials_Call{Call: _e.mock.On("ClearVPNCredentials", uuidOrName)}
+}
+
+func (_c *MockBackend_ClearVPNCredentials_Call) Run(run func(uuidOrName string)) *MockBackend_ClearVPNCredentials_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBackend_ClearVPNCredentials_Call) Return(_a0 error) *MockBackend_ClearVPNCredentials_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_ClearVPNCredentials_Call) RunAndReturn(run func(string) error) *MockBackend_ClearVPNCredentials_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Close provides a mock function with no fields
+func (_m *MockBackend) Close() {
+ _m.Called()
+}
+
+// MockBackend_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
+type MockBackend_Close_Call struct {
+ *mock.Call
+}
+
+// Close is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) Close() *MockBackend_Close_Call {
+ return &MockBackend_Close_Call{Call: _e.mock.On("Close")}
+}
+
+func (_c *MockBackend_Close_Call) Run(run func()) *MockBackend_Close_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_Close_Call) Return() *MockBackend_Close_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockBackend_Close_Call) RunAndReturn(run func()) *MockBackend_Close_Call {
+ _c.Run(run)
+ return _c
+}
+
+// ConnectEthernet provides a mock function with no fields
+func (_m *MockBackend) ConnectEthernet() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ConnectEthernet")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_ConnectEthernet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectEthernet'
+type MockBackend_ConnectEthernet_Call struct {
+ *mock.Call
+}
+
+// ConnectEthernet is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) ConnectEthernet() *MockBackend_ConnectEthernet_Call {
+ return &MockBackend_ConnectEthernet_Call{Call: _e.mock.On("ConnectEthernet")}
+}
+
+func (_c *MockBackend_ConnectEthernet_Call) Run(run func()) *MockBackend_ConnectEthernet_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_ConnectEthernet_Call) Return(_a0 error) *MockBackend_ConnectEthernet_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_ConnectEthernet_Call) RunAndReturn(run func() error) *MockBackend_ConnectEthernet_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ConnectVPN provides a mock function with given fields: uuidOrName, singleActive
+func (_m *MockBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ ret := _m.Called(uuidOrName, singleActive)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ConnectVPN")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, bool) error); ok {
+ r0 = rf(uuidOrName, singleActive)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_ConnectVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectVPN'
+type MockBackend_ConnectVPN_Call struct {
+ *mock.Call
+}
+
+// ConnectVPN is a helper method to define mock.On call
+// - uuidOrName string
+// - singleActive bool
+func (_e *MockBackend_Expecter) ConnectVPN(uuidOrName interface{}, singleActive interface{}) *MockBackend_ConnectVPN_Call {
+ return &MockBackend_ConnectVPN_Call{Call: _e.mock.On("ConnectVPN", uuidOrName, singleActive)}
+}
+
+func (_c *MockBackend_ConnectVPN_Call) Run(run func(uuidOrName string, singleActive bool)) *MockBackend_ConnectVPN_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(bool))
+ })
+ return _c
+}
+
+func (_c *MockBackend_ConnectVPN_Call) Return(_a0 error) *MockBackend_ConnectVPN_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_ConnectVPN_Call) RunAndReturn(run func(string, bool) error) *MockBackend_ConnectVPN_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ConnectWiFi provides a mock function with given fields: req
+func (_m *MockBackend) ConnectWiFi(req network.ConnectionRequest) error {
+ ret := _m.Called(req)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ConnectWiFi")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(network.ConnectionRequest) error); ok {
+ r0 = rf(req)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_ConnectWiFi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectWiFi'
+type MockBackend_ConnectWiFi_Call struct {
+ *mock.Call
+}
+
+// ConnectWiFi is a helper method to define mock.On call
+// - req network.ConnectionRequest
+func (_e *MockBackend_Expecter) ConnectWiFi(req interface{}) *MockBackend_ConnectWiFi_Call {
+ return &MockBackend_ConnectWiFi_Call{Call: _e.mock.On("ConnectWiFi", req)}
+}
+
+func (_c *MockBackend_ConnectWiFi_Call) Run(run func(req network.ConnectionRequest)) *MockBackend_ConnectWiFi_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(network.ConnectionRequest))
+ })
+ return _c
+}
+
+func (_c *MockBackend_ConnectWiFi_Call) Return(_a0 error) *MockBackend_ConnectWiFi_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_ConnectWiFi_Call) RunAndReturn(run func(network.ConnectionRequest) error) *MockBackend_ConnectWiFi_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// DisconnectAllVPN provides a mock function with no fields
+func (_m *MockBackend) DisconnectAllVPN() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for DisconnectAllVPN")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_DisconnectAllVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectAllVPN'
+type MockBackend_DisconnectAllVPN_Call struct {
+ *mock.Call
+}
+
+// DisconnectAllVPN is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) DisconnectAllVPN() *MockBackend_DisconnectAllVPN_Call {
+ return &MockBackend_DisconnectAllVPN_Call{Call: _e.mock.On("DisconnectAllVPN")}
+}
+
+func (_c *MockBackend_DisconnectAllVPN_Call) Run(run func()) *MockBackend_DisconnectAllVPN_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_DisconnectAllVPN_Call) Return(_a0 error) *MockBackend_DisconnectAllVPN_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_DisconnectAllVPN_Call) RunAndReturn(run func() error) *MockBackend_DisconnectAllVPN_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// DisconnectEthernet provides a mock function with no fields
+func (_m *MockBackend) DisconnectEthernet() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for DisconnectEthernet")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_DisconnectEthernet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectEthernet'
+type MockBackend_DisconnectEthernet_Call struct {
+ *mock.Call
+}
+
+// DisconnectEthernet is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) DisconnectEthernet() *MockBackend_DisconnectEthernet_Call {
+ return &MockBackend_DisconnectEthernet_Call{Call: _e.mock.On("DisconnectEthernet")}
+}
+
+func (_c *MockBackend_DisconnectEthernet_Call) Run(run func()) *MockBackend_DisconnectEthernet_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_DisconnectEthernet_Call) Return(_a0 error) *MockBackend_DisconnectEthernet_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_DisconnectEthernet_Call) RunAndReturn(run func() error) *MockBackend_DisconnectEthernet_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// DisconnectVPN provides a mock function with given fields: uuidOrName
+func (_m *MockBackend) DisconnectVPN(uuidOrName string) error {
+ ret := _m.Called(uuidOrName)
+
+ if len(ret) == 0 {
+ panic("no return value specified for DisconnectVPN")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(uuidOrName)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_DisconnectVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectVPN'
+type MockBackend_DisconnectVPN_Call struct {
+ *mock.Call
+}
+
+// DisconnectVPN is a helper method to define mock.On call
+// - uuidOrName string
+func (_e *MockBackend_Expecter) DisconnectVPN(uuidOrName interface{}) *MockBackend_DisconnectVPN_Call {
+ return &MockBackend_DisconnectVPN_Call{Call: _e.mock.On("DisconnectVPN", uuidOrName)}
+}
+
+func (_c *MockBackend_DisconnectVPN_Call) Run(run func(uuidOrName string)) *MockBackend_DisconnectVPN_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBackend_DisconnectVPN_Call) Return(_a0 error) *MockBackend_DisconnectVPN_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_DisconnectVPN_Call) RunAndReturn(run func(string) error) *MockBackend_DisconnectVPN_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// DisconnectWiFi provides a mock function with no fields
+func (_m *MockBackend) DisconnectWiFi() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for DisconnectWiFi")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_DisconnectWiFi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectWiFi'
+type MockBackend_DisconnectWiFi_Call struct {
+ *mock.Call
+}
+
+// DisconnectWiFi is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) DisconnectWiFi() *MockBackend_DisconnectWiFi_Call {
+ return &MockBackend_DisconnectWiFi_Call{Call: _e.mock.On("DisconnectWiFi")}
+}
+
+func (_c *MockBackend_DisconnectWiFi_Call) Run(run func()) *MockBackend_DisconnectWiFi_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_DisconnectWiFi_Call) Return(_a0 error) *MockBackend_DisconnectWiFi_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_DisconnectWiFi_Call) RunAndReturn(run func() error) *MockBackend_DisconnectWiFi_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ForgetWiFiNetwork provides a mock function with given fields: ssid
+func (_m *MockBackend) ForgetWiFiNetwork(ssid string) error {
+ ret := _m.Called(ssid)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ForgetWiFiNetwork")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(ssid)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_ForgetWiFiNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgetWiFiNetwork'
+type MockBackend_ForgetWiFiNetwork_Call struct {
+ *mock.Call
+}
+
+// ForgetWiFiNetwork is a helper method to define mock.On call
+// - ssid string
+func (_e *MockBackend_Expecter) ForgetWiFiNetwork(ssid interface{}) *MockBackend_ForgetWiFiNetwork_Call {
+ return &MockBackend_ForgetWiFiNetwork_Call{Call: _e.mock.On("ForgetWiFiNetwork", ssid)}
+}
+
+func (_c *MockBackend_ForgetWiFiNetwork_Call) Run(run func(ssid string)) *MockBackend_ForgetWiFiNetwork_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBackend_ForgetWiFiNetwork_Call) Return(_a0 error) *MockBackend_ForgetWiFiNetwork_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_ForgetWiFiNetwork_Call) RunAndReturn(run func(string) error) *MockBackend_ForgetWiFiNetwork_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetCurrentState provides a mock function with no fields
+func (_m *MockBackend) GetCurrentState() (*network.BackendState, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetCurrentState")
+ }
+
+ var r0 *network.BackendState
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (*network.BackendState, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() *network.BackendState); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*network.BackendState)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockBackend_GetCurrentState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCurrentState'
+type MockBackend_GetCurrentState_Call struct {
+ *mock.Call
+}
+
+// GetCurrentState is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) GetCurrentState() *MockBackend_GetCurrentState_Call {
+ return &MockBackend_GetCurrentState_Call{Call: _e.mock.On("GetCurrentState")}
+}
+
+func (_c *MockBackend_GetCurrentState_Call) Run(run func()) *MockBackend_GetCurrentState_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_GetCurrentState_Call) Return(_a0 *network.BackendState, _a1 error) *MockBackend_GetCurrentState_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBackend_GetCurrentState_Call) RunAndReturn(run func() (*network.BackendState, error)) *MockBackend_GetCurrentState_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetPromptBroker provides a mock function with no fields
+func (_m *MockBackend) GetPromptBroker() network.PromptBroker {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPromptBroker")
+ }
+
+ var r0 network.PromptBroker
+ if rf, ok := ret.Get(0).(func() network.PromptBroker); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(network.PromptBroker)
+ }
+ }
+
+ return r0
+}
+
+// MockBackend_GetPromptBroker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPromptBroker'
+type MockBackend_GetPromptBroker_Call struct {
+ *mock.Call
+}
+
+// GetPromptBroker is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) GetPromptBroker() *MockBackend_GetPromptBroker_Call {
+ return &MockBackend_GetPromptBroker_Call{Call: _e.mock.On("GetPromptBroker")}
+}
+
+func (_c *MockBackend_GetPromptBroker_Call) Run(run func()) *MockBackend_GetPromptBroker_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_GetPromptBroker_Call) Return(_a0 network.PromptBroker) *MockBackend_GetPromptBroker_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_GetPromptBroker_Call) RunAndReturn(run func() network.PromptBroker) *MockBackend_GetPromptBroker_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetWiFiEnabled provides a mock function with no fields
+func (_m *MockBackend) GetWiFiEnabled() (bool, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetWiFiEnabled")
+ }
+
+ 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
+}
+
+// MockBackend_GetWiFiEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiEnabled'
+type MockBackend_GetWiFiEnabled_Call struct {
+ *mock.Call
+}
+
+// GetWiFiEnabled is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) GetWiFiEnabled() *MockBackend_GetWiFiEnabled_Call {
+ return &MockBackend_GetWiFiEnabled_Call{Call: _e.mock.On("GetWiFiEnabled")}
+}
+
+func (_c *MockBackend_GetWiFiEnabled_Call) Run(run func()) *MockBackend_GetWiFiEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_GetWiFiEnabled_Call) Return(_a0 bool, _a1 error) *MockBackend_GetWiFiEnabled_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBackend_GetWiFiEnabled_Call) RunAndReturn(run func() (bool, error)) *MockBackend_GetWiFiEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetWiFiNetworkDetails provides a mock function with given fields: ssid
+func (_m *MockBackend) GetWiFiNetworkDetails(ssid string) (*network.NetworkInfoResponse, error) {
+ ret := _m.Called(ssid)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetWiFiNetworkDetails")
+ }
+
+ var r0 *network.NetworkInfoResponse
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (*network.NetworkInfoResponse, error)); ok {
+ return rf(ssid)
+ }
+ if rf, ok := ret.Get(0).(func(string) *network.NetworkInfoResponse); ok {
+ r0 = rf(ssid)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*network.NetworkInfoResponse)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(ssid)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockBackend_GetWiFiNetworkDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiNetworkDetails'
+type MockBackend_GetWiFiNetworkDetails_Call struct {
+ *mock.Call
+}
+
+// GetWiFiNetworkDetails is a helper method to define mock.On call
+// - ssid string
+func (_e *MockBackend_Expecter) GetWiFiNetworkDetails(ssid interface{}) *MockBackend_GetWiFiNetworkDetails_Call {
+ return &MockBackend_GetWiFiNetworkDetails_Call{Call: _e.mock.On("GetWiFiNetworkDetails", ssid)}
+}
+
+func (_c *MockBackend_GetWiFiNetworkDetails_Call) Run(run func(ssid string)) *MockBackend_GetWiFiNetworkDetails_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBackend_GetWiFiNetworkDetails_Call) Return(_a0 *network.NetworkInfoResponse, _a1 error) *MockBackend_GetWiFiNetworkDetails_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBackend_GetWiFiNetworkDetails_Call) RunAndReturn(run func(string) (*network.NetworkInfoResponse, error)) *MockBackend_GetWiFiNetworkDetails_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetWiredConnections provides a mock function with no fields
+func (_m *MockBackend) GetWiredConnections() ([]network.WiredConnection, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetWiredConnections")
+ }
+
+ var r0 []network.WiredConnection
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]network.WiredConnection, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []network.WiredConnection); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]network.WiredConnection)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockBackend_GetWiredConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiredConnections'
+type MockBackend_GetWiredConnections_Call struct {
+ *mock.Call
+}
+
+// GetWiredConnections is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) GetWiredConnections() *MockBackend_GetWiredConnections_Call {
+ return &MockBackend_GetWiredConnections_Call{Call: _e.mock.On("GetWiredConnections")}
+}
+
+func (_c *MockBackend_GetWiredConnections_Call) Run(run func()) *MockBackend_GetWiredConnections_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_GetWiredConnections_Call) Return(_a0 []network.WiredConnection, _a1 error) *MockBackend_GetWiredConnections_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBackend_GetWiredConnections_Call) RunAndReturn(run func() ([]network.WiredConnection, error)) *MockBackend_GetWiredConnections_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// GetWiredNetworkDetails provides a mock function with given fields: uuid
+func (_m *MockBackend) GetWiredNetworkDetails(uuid string) (*network.WiredNetworkInfoResponse, error) {
+ ret := _m.Called(uuid)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetWiredNetworkDetails")
+ }
+
+ var r0 *network.WiredNetworkInfoResponse
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (*network.WiredNetworkInfoResponse, error)); ok {
+ return rf(uuid)
+ }
+ if rf, ok := ret.Get(0).(func(string) *network.WiredNetworkInfoResponse); ok {
+ r0 = rf(uuid)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*network.WiredNetworkInfoResponse)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(uuid)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockBackend_GetWiredNetworkDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiredNetworkDetails'
+type MockBackend_GetWiredNetworkDetails_Call struct {
+ *mock.Call
+}
+
+// GetWiredNetworkDetails is a helper method to define mock.On call
+// - uuid string
+func (_e *MockBackend_Expecter) GetWiredNetworkDetails(uuid interface{}) *MockBackend_GetWiredNetworkDetails_Call {
+ return &MockBackend_GetWiredNetworkDetails_Call{Call: _e.mock.On("GetWiredNetworkDetails", uuid)}
+}
+
+func (_c *MockBackend_GetWiredNetworkDetails_Call) Run(run func(uuid string)) *MockBackend_GetWiredNetworkDetails_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string))
+ })
+ return _c
+}
+
+func (_c *MockBackend_GetWiredNetworkDetails_Call) Return(_a0 *network.WiredNetworkInfoResponse, _a1 error) *MockBackend_GetWiredNetworkDetails_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBackend_GetWiredNetworkDetails_Call) RunAndReturn(run func(string) (*network.WiredNetworkInfoResponse, error)) *MockBackend_GetWiredNetworkDetails_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Initialize provides a mock function with no fields
+func (_m *MockBackend) Initialize() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Initialize")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_Initialize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Initialize'
+type MockBackend_Initialize_Call struct {
+ *mock.Call
+}
+
+// Initialize is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) Initialize() *MockBackend_Initialize_Call {
+ return &MockBackend_Initialize_Call{Call: _e.mock.On("Initialize")}
+}
+
+func (_c *MockBackend_Initialize_Call) Run(run func()) *MockBackend_Initialize_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_Initialize_Call) Return(_a0 error) *MockBackend_Initialize_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_Initialize_Call) RunAndReturn(run func() error) *MockBackend_Initialize_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ListActiveVPN provides a mock function with no fields
+func (_m *MockBackend) ListActiveVPN() ([]network.VPNActive, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ListActiveVPN")
+ }
+
+ var r0 []network.VPNActive
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]network.VPNActive, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []network.VPNActive); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]network.VPNActive)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockBackend_ListActiveVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListActiveVPN'
+type MockBackend_ListActiveVPN_Call struct {
+ *mock.Call
+}
+
+// ListActiveVPN is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) ListActiveVPN() *MockBackend_ListActiveVPN_Call {
+ return &MockBackend_ListActiveVPN_Call{Call: _e.mock.On("ListActiveVPN")}
+}
+
+func (_c *MockBackend_ListActiveVPN_Call) Run(run func()) *MockBackend_ListActiveVPN_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_ListActiveVPN_Call) Return(_a0 []network.VPNActive, _a1 error) *MockBackend_ListActiveVPN_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBackend_ListActiveVPN_Call) RunAndReturn(run func() ([]network.VPNActive, error)) *MockBackend_ListActiveVPN_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ListVPNProfiles provides a mock function with no fields
+func (_m *MockBackend) ListVPNProfiles() ([]network.VPNProfile, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ListVPNProfiles")
+ }
+
+ var r0 []network.VPNProfile
+ var r1 error
+ if rf, ok := ret.Get(0).(func() ([]network.VPNProfile, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() []network.VPNProfile); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]network.VPNProfile)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockBackend_ListVPNProfiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVPNProfiles'
+type MockBackend_ListVPNProfiles_Call struct {
+ *mock.Call
+}
+
+// ListVPNProfiles is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) ListVPNProfiles() *MockBackend_ListVPNProfiles_Call {
+ return &MockBackend_ListVPNProfiles_Call{Call: _e.mock.On("ListVPNProfiles")}
+}
+
+func (_c *MockBackend_ListVPNProfiles_Call) Run(run func()) *MockBackend_ListVPNProfiles_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_ListVPNProfiles_Call) Return(_a0 []network.VPNProfile, _a1 error) *MockBackend_ListVPNProfiles_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockBackend_ListVPNProfiles_Call) RunAndReturn(run func() ([]network.VPNProfile, error)) *MockBackend_ListVPNProfiles_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// ScanWiFi provides a mock function with no fields
+func (_m *MockBackend) ScanWiFi() error {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ScanWiFi")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func() error); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_ScanWiFi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ScanWiFi'
+type MockBackend_ScanWiFi_Call struct {
+ *mock.Call
+}
+
+// ScanWiFi is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) ScanWiFi() *MockBackend_ScanWiFi_Call {
+ return &MockBackend_ScanWiFi_Call{Call: _e.mock.On("ScanWiFi")}
+}
+
+func (_c *MockBackend_ScanWiFi_Call) Run(run func()) *MockBackend_ScanWiFi_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_ScanWiFi_Call) Return(_a0 error) *MockBackend_ScanWiFi_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_ScanWiFi_Call) RunAndReturn(run func() error) *MockBackend_ScanWiFi_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetPromptBroker provides a mock function with given fields: broker
+func (_m *MockBackend) SetPromptBroker(broker network.PromptBroker) error {
+ ret := _m.Called(broker)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetPromptBroker")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(network.PromptBroker) error); ok {
+ r0 = rf(broker)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_SetPromptBroker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPromptBroker'
+type MockBackend_SetPromptBroker_Call struct {
+ *mock.Call
+}
+
+// SetPromptBroker is a helper method to define mock.On call
+// - broker network.PromptBroker
+func (_e *MockBackend_Expecter) SetPromptBroker(broker interface{}) *MockBackend_SetPromptBroker_Call {
+ return &MockBackend_SetPromptBroker_Call{Call: _e.mock.On("SetPromptBroker", broker)}
+}
+
+func (_c *MockBackend_SetPromptBroker_Call) Run(run func(broker network.PromptBroker)) *MockBackend_SetPromptBroker_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(network.PromptBroker))
+ })
+ return _c
+}
+
+func (_c *MockBackend_SetPromptBroker_Call) Return(_a0 error) *MockBackend_SetPromptBroker_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_SetPromptBroker_Call) RunAndReturn(run func(network.PromptBroker) error) *MockBackend_SetPromptBroker_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetWiFiAutoconnect provides a mock function with given fields: ssid, autoconnect
+func (_m *MockBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ ret := _m.Called(ssid, autoconnect)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetWiFiAutoconnect")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, bool) error); ok {
+ r0 = rf(ssid, autoconnect)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_SetWiFiAutoconnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWiFiAutoconnect'
+type MockBackend_SetWiFiAutoconnect_Call struct {
+ *mock.Call
+}
+
+// SetWiFiAutoconnect is a helper method to define mock.On call
+// - ssid string
+// - autoconnect bool
+func (_e *MockBackend_Expecter) SetWiFiAutoconnect(ssid interface{}, autoconnect interface{}) *MockBackend_SetWiFiAutoconnect_Call {
+ return &MockBackend_SetWiFiAutoconnect_Call{Call: _e.mock.On("SetWiFiAutoconnect", ssid, autoconnect)}
+}
+
+func (_c *MockBackend_SetWiFiAutoconnect_Call) Run(run func(ssid string, autoconnect bool)) *MockBackend_SetWiFiAutoconnect_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(bool))
+ })
+ return _c
+}
+
+func (_c *MockBackend_SetWiFiAutoconnect_Call) Return(_a0 error) *MockBackend_SetWiFiAutoconnect_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_SetWiFiAutoconnect_Call) RunAndReturn(run func(string, bool) error) *MockBackend_SetWiFiAutoconnect_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetWiFiEnabled provides a mock function with given fields: enabled
+func (_m *MockBackend) SetWiFiEnabled(enabled bool) error {
+ ret := _m.Called(enabled)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetWiFiEnabled")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(bool) error); ok {
+ r0 = rf(enabled)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_SetWiFiEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWiFiEnabled'
+type MockBackend_SetWiFiEnabled_Call struct {
+ *mock.Call
+}
+
+// SetWiFiEnabled is a helper method to define mock.On call
+// - enabled bool
+func (_e *MockBackend_Expecter) SetWiFiEnabled(enabled interface{}) *MockBackend_SetWiFiEnabled_Call {
+ return &MockBackend_SetWiFiEnabled_Call{Call: _e.mock.On("SetWiFiEnabled", enabled)}
+}
+
+func (_c *MockBackend_SetWiFiEnabled_Call) Run(run func(enabled bool)) *MockBackend_SetWiFiEnabled_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(bool))
+ })
+ return _c
+}
+
+func (_c *MockBackend_SetWiFiEnabled_Call) Return(_a0 error) *MockBackend_SetWiFiEnabled_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_SetWiFiEnabled_Call) RunAndReturn(run func(bool) error) *MockBackend_SetWiFiEnabled_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// StartMonitoring provides a mock function with given fields: onStateChange
+func (_m *MockBackend) StartMonitoring(onStateChange func()) error {
+ ret := _m.Called(onStateChange)
+
+ if len(ret) == 0 {
+ panic("no return value specified for StartMonitoring")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(func()) error); ok {
+ r0 = rf(onStateChange)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_StartMonitoring_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartMonitoring'
+type MockBackend_StartMonitoring_Call struct {
+ *mock.Call
+}
+
+// StartMonitoring is a helper method to define mock.On call
+// - onStateChange func()
+func (_e *MockBackend_Expecter) StartMonitoring(onStateChange interface{}) *MockBackend_StartMonitoring_Call {
+ return &MockBackend_StartMonitoring_Call{Call: _e.mock.On("StartMonitoring", onStateChange)}
+}
+
+func (_c *MockBackend_StartMonitoring_Call) Run(run func(onStateChange func())) *MockBackend_StartMonitoring_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(func()))
+ })
+ return _c
+}
+
+func (_c *MockBackend_StartMonitoring_Call) Return(_a0 error) *MockBackend_StartMonitoring_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_StartMonitoring_Call) RunAndReturn(run func(func()) error) *MockBackend_StartMonitoring_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// StopMonitoring provides a mock function with no fields
+func (_m *MockBackend) StopMonitoring() {
+ _m.Called()
+}
+
+// MockBackend_StopMonitoring_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopMonitoring'
+type MockBackend_StopMonitoring_Call struct {
+ *mock.Call
+}
+
+// StopMonitoring is a helper method to define mock.On call
+func (_e *MockBackend_Expecter) StopMonitoring() *MockBackend_StopMonitoring_Call {
+ return &MockBackend_StopMonitoring_Call{Call: _e.mock.On("StopMonitoring")}
+}
+
+func (_c *MockBackend_StopMonitoring_Call) Run(run func()) *MockBackend_StopMonitoring_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockBackend_StopMonitoring_Call) Return() *MockBackend_StopMonitoring_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockBackend_StopMonitoring_Call) RunAndReturn(run func()) *MockBackend_StopMonitoring_Call {
+ _c.Run(run)
+ return _c
+}
+
+// SubmitCredentials provides a mock function with given fields: token, secrets, save
+func (_m *MockBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ ret := _m.Called(token, secrets, save)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SubmitCredentials")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, map[string]string, bool) error); ok {
+ r0 = rf(token, secrets, save)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockBackend_SubmitCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubmitCredentials'
+type MockBackend_SubmitCredentials_Call struct {
+ *mock.Call
+}
+
+// SubmitCredentials is a helper method to define mock.On call
+// - token string
+// - secrets map[string]string
+// - save bool
+func (_e *MockBackend_Expecter) SubmitCredentials(token interface{}, secrets interface{}, save interface{}) *MockBackend_SubmitCredentials_Call {
+ return &MockBackend_SubmitCredentials_Call{Call: _e.mock.On("SubmitCredentials", token, secrets, save)}
+}
+
+func (_c *MockBackend_SubmitCredentials_Call) Run(run func(token string, secrets map[string]string, save bool)) *MockBackend_SubmitCredentials_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(string), args[1].(map[string]string), args[2].(bool))
+ })
+ return _c
+}
+
+func (_c *MockBackend_SubmitCredentials_Call) Return(_a0 error) *MockBackend_SubmitCredentials_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockBackend_SubmitCredentials_Call) RunAndReturn(run func(string, map[string]string, bool) error) *MockBackend_SubmitCredentials_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockBackend creates a new instance of MockBackend. 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 NewMockBackend(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockBackend {
+ mock := &MockBackend{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/backend/internal/plugins/manager.go b/backend/internal/plugins/manager.go
new file mode 100644
index 00000000..0c277eb0
--- /dev/null
+++ b/backend/internal/plugins/manager.go
@@ -0,0 +1,430 @@
+package plugins
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/afero"
+)
+
+type Manager struct {
+ fs afero.Fs
+ pluginsDir string
+ gitClient GitClient
+}
+
+func NewManager() (*Manager, error) {
+ return NewManagerWithFs(afero.NewOsFs())
+}
+
+func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
+ pluginsDir := getPluginsDir()
+ return &Manager{
+ fs: fs,
+ pluginsDir: pluginsDir,
+ gitClient: &realGitClient{},
+ }, nil
+}
+
+func getPluginsDir() string {
+ configHome := os.Getenv("XDG_CONFIG_HOME")
+ if configHome == "" {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return filepath.Join(os.TempDir(), "DankMaterialShell", "plugins")
+ }
+ configHome = filepath.Join(homeDir, ".config")
+ }
+ return filepath.Join(configHome, "DankMaterialShell", "plugins")
+}
+
+func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
+ pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
+ exists, err := afero.DirExists(m.fs, pluginPath)
+ if err != nil {
+ return false, err
+ }
+ if exists {
+ return true, nil
+ }
+
+ systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
+ systemExists, err := afero.DirExists(m.fs, systemPluginPath)
+ if err != nil {
+ return false, err
+ }
+ return systemExists, nil
+}
+
+func (m *Manager) Install(plugin Plugin) error {
+ pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
+
+ exists, err := afero.DirExists(m.fs, pluginPath)
+ if err != nil {
+ return fmt.Errorf("failed to check if plugin exists: %w", err)
+ }
+
+ if exists {
+ return fmt.Errorf("plugin already installed: %s", plugin.Name)
+ }
+
+ if err := m.fs.MkdirAll(m.pluginsDir, 0755); err != nil {
+ return fmt.Errorf("failed to create plugins directory: %w", err)
+ }
+
+ reposDir := filepath.Join(m.pluginsDir, ".repos")
+ if err := m.fs.MkdirAll(reposDir, 0755); err != nil {
+ return fmt.Errorf("failed to create repos directory: %w", err)
+ }
+
+ if plugin.Path != "" {
+ repoName := m.getRepoName(plugin.Repo)
+ repoPath := filepath.Join(reposDir, repoName)
+
+ repoExists, err := afero.DirExists(m.fs, repoPath)
+ if err != nil {
+ return fmt.Errorf("failed to check if repo exists: %w", err)
+ }
+
+ if !repoExists {
+ if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
+ m.fs.RemoveAll(repoPath)
+ return fmt.Errorf("failed to clone repository: %w", err)
+ }
+ } else {
+ // Pull latest changes if repo already exists
+ if err := m.gitClient.Pull(repoPath); err != nil {
+ // If pull fails (e.g., corrupted shallow clone), delete and re-clone
+ if err := m.fs.RemoveAll(repoPath); err != nil {
+ return fmt.Errorf("failed to remove corrupted repository: %w", err)
+ }
+
+ if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
+ return fmt.Errorf("failed to re-clone repository: %w", err)
+ }
+ }
+ }
+
+ sourcePath := filepath.Join(repoPath, plugin.Path)
+ sourceExists, err := afero.DirExists(m.fs, sourcePath)
+ if err != nil {
+ return fmt.Errorf("failed to check plugin path: %w", err)
+ }
+ if !sourceExists {
+ return fmt.Errorf("plugin path does not exist in repository: %s", plugin.Path)
+ }
+
+ if err := m.createSymlink(sourcePath, pluginPath); err != nil {
+ return fmt.Errorf("failed to create symlink: %w", err)
+ }
+
+ metaPath := pluginPath + ".meta"
+ metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
+ if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0644); err != nil {
+ return fmt.Errorf("failed to write metadata: %w", err)
+ }
+ } else {
+ if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
+ m.fs.RemoveAll(pluginPath)
+ return fmt.Errorf("failed to clone plugin: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) getRepoName(repoURL string) string {
+ hash := sha256.Sum256([]byte(repoURL))
+ return hex.EncodeToString(hash[:])[:16]
+}
+
+func (m *Manager) createSymlink(source, dest string) error {
+ if symlinkFs, ok := m.fs.(afero.Symlinker); ok {
+ return symlinkFs.SymlinkIfPossible(source, dest)
+ }
+ return os.Symlink(source, dest)
+}
+
+func (m *Manager) Update(plugin Plugin) error {
+ pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
+
+ exists, err := afero.DirExists(m.fs, pluginPath)
+ if err != nil {
+ return fmt.Errorf("failed to check if plugin exists: %w", err)
+ }
+
+ if !exists {
+ systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
+ systemExists, err := afero.DirExists(m.fs, systemPluginPath)
+ if err != nil {
+ return fmt.Errorf("failed to check if plugin exists: %w", err)
+ }
+ if systemExists {
+ return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
+ }
+ return fmt.Errorf("plugin not installed: %s", plugin.Name)
+ }
+
+ metaPath := pluginPath + ".meta"
+ metaExists, err := afero.Exists(m.fs, metaPath)
+ if err != nil {
+ return fmt.Errorf("failed to check metadata: %w", err)
+ }
+
+ if metaExists {
+ reposDir := filepath.Join(m.pluginsDir, ".repos")
+ repoName := m.getRepoName(plugin.Repo)
+ repoPath := filepath.Join(reposDir, repoName)
+
+ // Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
+ if err := m.gitClient.Pull(repoPath); err != nil {
+ // Repository is likely corrupted or has issues, delete and re-clone
+ if err := m.fs.RemoveAll(repoPath); err != nil {
+ return fmt.Errorf("failed to remove corrupted repository: %w", err)
+ }
+
+ if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
+ return fmt.Errorf("failed to re-clone repository: %w", err)
+ }
+ }
+ } else {
+ // Try to pull, if it fails, delete and re-clone
+ if err := m.gitClient.Pull(pluginPath); err != nil {
+ if err := m.fs.RemoveAll(pluginPath); err != nil {
+ return fmt.Errorf("failed to remove corrupted plugin: %w", err)
+ }
+
+ if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
+ return fmt.Errorf("failed to re-clone plugin: %w", err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) Uninstall(plugin Plugin) error {
+ pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
+
+ exists, err := afero.DirExists(m.fs, pluginPath)
+ if err != nil {
+ return fmt.Errorf("failed to check if plugin exists: %w", err)
+ }
+
+ if !exists {
+ systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
+ systemExists, err := afero.DirExists(m.fs, systemPluginPath)
+ if err != nil {
+ return fmt.Errorf("failed to check if plugin exists: %w", err)
+ }
+ if systemExists {
+ return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
+ }
+ return fmt.Errorf("plugin not installed: %s", plugin.Name)
+ }
+
+ metaPath := pluginPath + ".meta"
+ metaExists, err := afero.Exists(m.fs, metaPath)
+ if err != nil {
+ return fmt.Errorf("failed to check metadata: %w", err)
+ }
+
+ if metaExists {
+ reposDir := filepath.Join(m.pluginsDir, ".repos")
+ repoName := m.getRepoName(plugin.Repo)
+ repoPath := filepath.Join(reposDir, repoName)
+
+ shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
+ if err != nil {
+ return fmt.Errorf("failed to check repo cleanup: %w", err)
+ }
+
+ if err := m.fs.Remove(pluginPath); err != nil {
+ return fmt.Errorf("failed to remove symlink: %w", err)
+ }
+
+ if err := m.fs.Remove(metaPath); err != nil {
+ return fmt.Errorf("failed to remove metadata: %w", err)
+ }
+
+ if shouldCleanup {
+ if err := m.fs.RemoveAll(repoPath); err != nil {
+ return fmt.Errorf("failed to cleanup repository: %w", err)
+ }
+ }
+ } else {
+ if err := m.fs.RemoveAll(pluginPath); err != nil {
+ return fmt.Errorf("failed to remove plugin: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
+ installed, err := m.ListInstalled()
+ if err != nil {
+ return false, err
+ }
+
+ registry, err := NewRegistry()
+ if err != nil {
+ return false, err
+ }
+
+ allPlugins, err := registry.List()
+ if err != nil {
+ return false, err
+ }
+
+ for _, id := range installed {
+ if id == excludePlugin {
+ continue
+ }
+
+ for _, p := range allPlugins {
+ if p.ID == id && p.Repo == repoURL && p.Path != "" {
+ return false, nil
+ }
+ }
+ }
+
+ return true, nil
+}
+
+func (m *Manager) ListInstalled() ([]string, error) {
+ installedMap := make(map[string]bool)
+
+ exists, err := afero.DirExists(m.fs, m.pluginsDir)
+ if err != nil {
+ return nil, err
+ }
+
+ if exists {
+ entries, err := afero.ReadDir(m.fs, m.pluginsDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read plugins directory: %w", err)
+ }
+
+ for _, entry := range entries {
+ name := entry.Name()
+ if name == ".repos" || strings.HasSuffix(name, ".meta") {
+ continue
+ }
+
+ fullPath := filepath.Join(m.pluginsDir, name)
+ isPlugin := false
+
+ if entry.IsDir() {
+ isPlugin = true
+ } else if entry.Mode()&os.ModeSymlink != 0 {
+ isPlugin = true
+ } else {
+ info, err := m.fs.Stat(fullPath)
+ if err == nil && info.IsDir() {
+ isPlugin = true
+ }
+ }
+
+ if isPlugin {
+ // Read plugin.json to get the actual plugin ID
+ pluginID := m.getPluginID(fullPath)
+ if pluginID != "" {
+ installedMap[pluginID] = true
+ }
+ }
+ }
+ }
+
+ systemPluginsDir := "/etc/xdg/quickshell/dms-plugins"
+ systemExists, err := afero.DirExists(m.fs, systemPluginsDir)
+ if err == nil && systemExists {
+ entries, err := afero.ReadDir(m.fs, systemPluginsDir)
+ if err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() {
+ fullPath := filepath.Join(systemPluginsDir, entry.Name())
+ // Read plugin.json to get the actual plugin ID
+ pluginID := m.getPluginID(fullPath)
+ if pluginID != "" {
+ installedMap[pluginID] = true
+ }
+ }
+ }
+ }
+ }
+
+ var installed []string
+ for name := range installedMap {
+ installed = append(installed, name)
+ }
+
+ return installed, nil
+}
+
+// getPluginID reads the plugin.json file and returns the plugin ID
+func (m *Manager) getPluginID(pluginPath string) string {
+ manifestPath := filepath.Join(pluginPath, "plugin.json")
+ data, err := afero.ReadFile(m.fs, manifestPath)
+ if err != nil {
+ return ""
+ }
+
+ var manifest struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return ""
+ }
+
+ return manifest.ID
+}
+
+func (m *Manager) GetPluginsDir() string {
+ return m.pluginsDir
+}
+
+func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) {
+ pluginPath := filepath.Join(m.pluginsDir, pluginID)
+
+ exists, err := afero.DirExists(m.fs, pluginPath)
+ if err != nil {
+ return false, fmt.Errorf("failed to check if plugin exists: %w", err)
+ }
+
+ if !exists {
+ systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", pluginID)
+ systemExists, err := afero.DirExists(m.fs, systemPluginPath)
+ if err != nil {
+ return false, fmt.Errorf("failed to check system plugin: %w", err)
+ }
+ if systemExists {
+ return false, nil
+ }
+ return false, fmt.Errorf("plugin not installed: %s", pluginID)
+ }
+
+ // Check if there's a .meta file (plugin installed from a monorepo)
+ metaPath := pluginPath + ".meta"
+ metaExists, err := afero.Exists(m.fs, metaPath)
+ if err != nil {
+ return false, fmt.Errorf("failed to check metadata: %w", err)
+ }
+
+ if metaExists {
+ // Plugin is from a monorepo, check the repo directory
+ reposDir := filepath.Join(m.pluginsDir, ".repos")
+ repoName := m.getRepoName(plugin.Repo)
+ repoPath := filepath.Join(reposDir, repoName)
+
+ return m.gitClient.HasUpdates(repoPath)
+ }
+
+ // Plugin is a standalone repo
+ return m.gitClient.HasUpdates(pluginPath)
+}
diff --git a/backend/internal/plugins/manager_test.go b/backend/internal/plugins/manager_test.go
new file mode 100644
index 00000000..5dd9d010
--- /dev/null
+++ b/backend/internal/plugins/manager_test.go
@@ -0,0 +1,247 @@
+package plugins
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
+ fs := afero.NewMemMapFs()
+ pluginsDir := "/test-plugins"
+ manager := &Manager{
+ fs: fs,
+ pluginsDir: pluginsDir,
+ gitClient: &mockGitClient{},
+ }
+ return manager, fs, pluginsDir
+}
+
+func TestNewManager(t *testing.T) {
+ manager, err := NewManager()
+ assert.NoError(t, err)
+ assert.NotNil(t, manager)
+ assert.NotEmpty(t, manager.pluginsDir)
+}
+
+func TestGetPluginsDir(t *testing.T) {
+ t.Run("uses XDG_CONFIG_HOME when set", func(t *testing.T) {
+ oldConfig := os.Getenv("XDG_CONFIG_HOME")
+ defer func() {
+ if oldConfig != "" {
+ os.Setenv("XDG_CONFIG_HOME", oldConfig)
+ } else {
+ os.Unsetenv("XDG_CONFIG_HOME")
+ }
+ }()
+
+ os.Setenv("XDG_CONFIG_HOME", "/tmp/test-config")
+ dir := getPluginsDir()
+ assert.Equal(t, "/tmp/test-config/DankMaterialShell/plugins", dir)
+ })
+
+ t.Run("falls back to home directory", func(t *testing.T) {
+ oldConfig := os.Getenv("XDG_CONFIG_HOME")
+ defer func() {
+ if oldConfig != "" {
+ os.Setenv("XDG_CONFIG_HOME", oldConfig)
+ } else {
+ os.Unsetenv("XDG_CONFIG_HOME")
+ }
+ }()
+
+ os.Unsetenv("XDG_CONFIG_HOME")
+ dir := getPluginsDir()
+ assert.Contains(t, dir, ".config/DankMaterialShell/plugins")
+ })
+}
+
+func TestIsInstalled(t *testing.T) {
+ t.Run("returns true when plugin is installed", func(t *testing.T) {
+ manager, fs, pluginsDir := setupTestManager(t)
+
+ plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
+ pluginPath := filepath.Join(pluginsDir, plugin.ID)
+ err := fs.MkdirAll(pluginPath, 0755)
+ require.NoError(t, err)
+
+ installed, err := manager.IsInstalled(plugin)
+ assert.NoError(t, err)
+ assert.True(t, installed)
+ })
+
+ t.Run("returns false when plugin is not installed", func(t *testing.T) {
+ manager, _, _ := setupTestManager(t)
+
+ plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
+ installed, err := manager.IsInstalled(plugin)
+ assert.NoError(t, err)
+ assert.False(t, installed)
+ })
+}
+
+func TestInstall(t *testing.T) {
+ t.Run("installs plugin successfully", func(t *testing.T) {
+ manager, fs, pluginsDir := setupTestManager(t)
+
+ plugin := Plugin{
+ ID: "test-plugin",
+ Name: "TestPlugin",
+ Repo: "https://github.com/test/plugin",
+ }
+
+ cloneCalled := false
+ mockGit := &mockGitClient{
+ cloneFunc: func(path string, url string) error {
+ cloneCalled = true
+ assert.Equal(t, filepath.Join(pluginsDir, plugin.ID), path)
+ assert.Equal(t, plugin.Repo, url)
+ return fs.MkdirAll(path, 0755)
+ },
+ }
+ manager.gitClient = mockGit
+
+ err := manager.Install(plugin)
+ assert.NoError(t, err)
+ assert.True(t, cloneCalled)
+
+ exists, _ := afero.DirExists(fs, filepath.Join(pluginsDir, plugin.ID))
+ assert.True(t, exists)
+ })
+
+ t.Run("returns error when plugin already installed", func(t *testing.T) {
+ manager, fs, pluginsDir := setupTestManager(t)
+
+ plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
+ pluginPath := filepath.Join(pluginsDir, plugin.ID)
+ err := fs.MkdirAll(pluginPath, 0755)
+ require.NoError(t, err)
+
+ err = manager.Install(plugin)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "already installed")
+ })
+
+ t.Run("installs monorepo plugin with symlink", func(t *testing.T) {
+ t.Skip("Skipping symlink test as MemMapFs doesn't support symlinks")
+ })
+}
+
+func TestManagerUpdate(t *testing.T) {
+ t.Run("updates plugin successfully", func(t *testing.T) {
+ manager, fs, pluginsDir := setupTestManager(t)
+
+ plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
+ pluginPath := filepath.Join(pluginsDir, plugin.ID)
+ err := fs.MkdirAll(pluginPath, 0755)
+ require.NoError(t, err)
+
+ pullCalled := false
+ mockGit := &mockGitClient{
+ pullFunc: func(path string) error {
+ pullCalled = true
+ assert.Equal(t, pluginPath, path)
+ return nil
+ },
+ }
+ manager.gitClient = mockGit
+
+ err = manager.Update(plugin)
+ assert.NoError(t, err)
+ assert.True(t, pullCalled)
+ })
+
+ t.Run("returns error when plugin not installed", func(t *testing.T) {
+ manager, _, _ := setupTestManager(t)
+
+ plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
+ err := manager.Update(plugin)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not installed")
+ })
+}
+
+func TestUninstall(t *testing.T) {
+ t.Run("uninstalls plugin successfully", func(t *testing.T) {
+ manager, fs, pluginsDir := setupTestManager(t)
+
+ plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
+ pluginPath := filepath.Join(pluginsDir, plugin.ID)
+ err := fs.MkdirAll(pluginPath, 0755)
+ require.NoError(t, err)
+
+ err = manager.Uninstall(plugin)
+ assert.NoError(t, err)
+
+ exists, _ := afero.DirExists(fs, pluginPath)
+ assert.False(t, exists)
+ })
+
+ t.Run("returns error when plugin not installed", func(t *testing.T) {
+ manager, _, _ := setupTestManager(t)
+
+ plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
+ err := manager.Uninstall(plugin)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not installed")
+ })
+}
+
+func TestListInstalled(t *testing.T) {
+ t.Run("lists installed plugins", func(t *testing.T) {
+ manager, fs, pluginsDir := setupTestManager(t)
+
+ err := fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
+ require.NoError(t, err)
+ err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
+ require.NoError(t, err)
+
+ err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin2"), 0755)
+ require.NoError(t, err)
+ err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin2", "plugin.json"), []byte(`{"id":"Plugin2"}`), 0644)
+ require.NoError(t, err)
+
+ installed, err := manager.ListInstalled()
+ assert.NoError(t, err)
+ assert.Len(t, installed, 2)
+ assert.Contains(t, installed, "Plugin1")
+ assert.Contains(t, installed, "Plugin2")
+ })
+
+ t.Run("returns empty list when no plugins installed", func(t *testing.T) {
+ manager, _, _ := setupTestManager(t)
+
+ installed, err := manager.ListInstalled()
+ assert.NoError(t, err)
+ assert.Empty(t, installed)
+ })
+
+ t.Run("ignores files and .repos directory", func(t *testing.T) {
+ manager, fs, pluginsDir := setupTestManager(t)
+
+ err := fs.MkdirAll(pluginsDir, 0755)
+ require.NoError(t, err)
+ err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
+ require.NoError(t, err)
+ err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
+ require.NoError(t, err)
+ err = fs.MkdirAll(filepath.Join(pluginsDir, ".repos"), 0755)
+ require.NoError(t, err)
+ err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("test"), 0644)
+ require.NoError(t, err)
+
+ installed, err := manager.ListInstalled()
+ assert.NoError(t, err)
+ assert.Len(t, installed, 1)
+ assert.Equal(t, "Plugin1", installed[0])
+ })
+}
+
+func TestManagerGetPluginsDir(t *testing.T) {
+ manager, _, pluginsDir := setupTestManager(t)
+ assert.Equal(t, pluginsDir, manager.GetPluginsDir())
+}
diff --git a/backend/internal/plugins/registry.go b/backend/internal/plugins/registry.go
new file mode 100644
index 00000000..f2e626df
--- /dev/null
+++ b/backend/internal/plugins/registry.go
@@ -0,0 +1,256 @@
+package plugins
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/spf13/afero"
+)
+
+const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
+
+type Plugin struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Capabilities []string `json:"capabilities"`
+ Category string `json:"category"`
+ Repo string `json:"repo"`
+ Path string `json:"path,omitempty"`
+ Author string `json:"author"`
+ Description string `json:"description"`
+ Dependencies []string `json:"dependencies,omitempty"`
+ Compositors []string `json:"compositors"`
+ Distro []string `json:"distro"`
+ Screenshot string `json:"screenshot,omitempty"`
+}
+
+type GitClient interface {
+ PlainClone(path string, url string) error
+ Pull(path string) error
+ HasUpdates(path string) (bool, error)
+}
+
+type realGitClient struct{}
+
+func (g *realGitClient) PlainClone(path string, url string) error {
+ _, err := git.PlainClone(path, &git.CloneOptions{
+ URL: url,
+ Progress: os.Stdout,
+ })
+ return err
+}
+
+func (g *realGitClient) Pull(path string) error {
+ repo, err := git.PlainOpen(path)
+ if err != nil {
+ return err
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ return err
+ }
+
+ err = worktree.Pull(&git.PullOptions{})
+ if err != nil && err.Error() != "already up-to-date" {
+ return err
+ }
+
+ return nil
+}
+
+func (g *realGitClient) HasUpdates(path string) (bool, error) {
+ repo, err := git.PlainOpen(path)
+ if err != nil {
+ return false, err
+ }
+
+ // Fetch remote changes
+ err = repo.Fetch(&git.FetchOptions{})
+ if err != nil && err.Error() != "already up-to-date" {
+ // If fetch fails, we can't determine if there are updates
+ // Return false and the error
+ return false, err
+ }
+
+ // Get the HEAD reference
+ head, err := repo.Head()
+ if err != nil {
+ return false, err
+ }
+
+ // Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
+ remote, err := repo.Remote("origin")
+ if err != nil {
+ return false, err
+ }
+
+ refs, err := remote.List(&git.ListOptions{})
+ if err != nil {
+ return false, err
+ }
+
+ // Find the default branch remote ref
+ var remoteHead string
+ for _, ref := range refs {
+ if ref.Name().IsBranch() {
+ // Try common branch names
+ if ref.Name().Short() == "main" || ref.Name().Short() == "master" {
+ remoteHead = ref.Hash().String()
+ break
+ }
+ }
+ }
+
+ // If we couldn't find a remote HEAD, assume no updates
+ if remoteHead == "" {
+ return false, nil
+ }
+
+ // Compare local HEAD with remote HEAD
+ return head.Hash().String() != remoteHead, nil
+}
+
+type Registry struct {
+ fs afero.Fs
+ cacheDir string
+ plugins []Plugin
+ git GitClient
+}
+
+func NewRegistry() (*Registry, error) {
+ return NewRegistryWithFs(afero.NewOsFs())
+}
+
+func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
+ cacheDir := getCacheDir()
+ return &Registry{
+ fs: fs,
+ cacheDir: cacheDir,
+ git: &realGitClient{},
+ }, nil
+}
+
+func getCacheDir() string {
+ return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
+}
+
+func (r *Registry) Update() error {
+ exists, err := afero.DirExists(r.fs, r.cacheDir)
+ if err != nil {
+ return fmt.Errorf("failed to check cache directory: %w", err)
+ }
+
+ if !exists {
+ if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
+ return fmt.Errorf("failed to create cache directory: %w", err)
+ }
+
+ if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
+ return fmt.Errorf("failed to clone registry: %w", err)
+ }
+ } else {
+ // Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
+ if err := r.git.Pull(r.cacheDir); err != nil {
+ // Repository is likely corrupted or has issues, delete and re-clone
+ if err := r.fs.RemoveAll(r.cacheDir); err != nil {
+ return fmt.Errorf("failed to remove corrupted registry: %w", err)
+ }
+
+ if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
+ return fmt.Errorf("failed to create cache directory: %w", err)
+ }
+
+ if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
+ return fmt.Errorf("failed to re-clone registry: %w", err)
+ }
+ }
+ }
+
+ return r.loadPlugins()
+}
+
+func (r *Registry) loadPlugins() error {
+ pluginsDir := filepath.Join(r.cacheDir, "plugins")
+
+ entries, err := afero.ReadDir(r.fs, pluginsDir)
+ if err != nil {
+ return fmt.Errorf("failed to read plugins directory: %w", err)
+ }
+
+ r.plugins = []Plugin{}
+
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
+ continue
+ }
+
+ data, err := afero.ReadFile(r.fs, filepath.Join(pluginsDir, entry.Name()))
+ if err != nil {
+ continue
+ }
+
+ var plugin Plugin
+ if err := json.Unmarshal(data, &plugin); err != nil {
+ continue
+ }
+
+ if plugin.ID == "" {
+ plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
+ }
+
+ r.plugins = append(r.plugins, plugin)
+ }
+
+ return nil
+}
+
+func (r *Registry) List() ([]Plugin, error) {
+ if len(r.plugins) == 0 {
+ if err := r.Update(); err != nil {
+ return nil, err
+ }
+ }
+
+ return SortByFirstParty(r.plugins), nil
+}
+
+func (r *Registry) Search(query string) ([]Plugin, error) {
+ allPlugins, err := r.List()
+ if err != nil {
+ return nil, err
+ }
+
+ if query == "" {
+ return allPlugins, nil
+ }
+
+ return SortByFirstParty(FuzzySearch(query, allPlugins)), nil
+}
+
+func (r *Registry) Get(idOrName string) (*Plugin, error) {
+ plugins, err := r.List()
+ if err != nil {
+ return nil, err
+ }
+
+ // First, try to find by ID (preferred method)
+ for _, p := range plugins {
+ if p.ID == idOrName {
+ return &p, nil
+ }
+ }
+
+ // Fallback to name for backward compatibility
+ for _, p := range plugins {
+ if p.Name == idOrName {
+ return &p, nil
+ }
+ }
+
+ return nil, fmt.Errorf("plugin not found: %s", idOrName)
+}
diff --git a/backend/internal/plugins/registry_test.go b/backend/internal/plugins/registry_test.go
new file mode 100644
index 00000000..0abcacb4
--- /dev/null
+++ b/backend/internal/plugins/registry_test.go
@@ -0,0 +1,326 @@
+package plugins
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockGitClient struct {
+ cloneFunc func(path string, url string) error
+ pullFunc func(path string) error
+ hasUpdatesFunc func(path string) (bool, error)
+}
+
+func (m *mockGitClient) PlainClone(path string, url string) error {
+ if m.cloneFunc != nil {
+ return m.cloneFunc(path, url)
+ }
+ return nil
+}
+
+func (m *mockGitClient) Pull(path string) error {
+ if m.pullFunc != nil {
+ return m.pullFunc(path)
+ }
+ return nil
+}
+
+func (m *mockGitClient) HasUpdates(path string) (bool, error) {
+ if m.hasUpdatesFunc != nil {
+ return m.hasUpdatesFunc(path)
+ }
+ return false, nil
+}
+
+func TestNewRegistry(t *testing.T) {
+ registry, err := NewRegistry()
+ assert.NoError(t, err)
+ assert.NotNil(t, registry)
+ assert.NotEmpty(t, registry.cacheDir)
+}
+
+func TestGetCacheDir(t *testing.T) {
+ cacheDir := getCacheDir()
+ assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
+}
+
+func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
+ fs := afero.NewMemMapFs()
+ tmpDir := "/test-cache"
+ registry := &Registry{
+ fs: fs,
+ cacheDir: tmpDir,
+ plugins: []Plugin{},
+ git: &mockGitClient{},
+ }
+ return registry, fs, tmpDir
+}
+
+func createTestPlugin(t *testing.T, fs afero.Fs, dir string, filename string, plugin Plugin) {
+ pluginsDir := filepath.Join(dir, "plugins")
+ err := fs.MkdirAll(pluginsDir, 0755)
+ require.NoError(t, err)
+
+ data, err := json.Marshal(plugin)
+ require.NoError(t, err)
+
+ err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0644)
+ require.NoError(t, err)
+}
+
+func TestLoadPlugins(t *testing.T) {
+ t.Run("loads valid plugin files", func(t *testing.T) {
+ registry, fs, tmpDir := setupTestRegistry(t)
+
+ plugin1 := Plugin{
+ Name: "TestPlugin1",
+ Capabilities: []string{"dankbar-widget"},
+ Category: "monitoring",
+ Repo: "https://github.com/test/plugin1",
+ Author: "Test Author",
+ Description: "Test plugin 1",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+
+ plugin2 := Plugin{
+ Name: "TestPlugin2",
+ Capabilities: []string{"system-tray"},
+ Category: "utilities",
+ Repo: "https://github.com/test/plugin2",
+ Author: "Another Author",
+ Description: "Test plugin 2",
+ Dependencies: []string{"dep1", "dep2"},
+ Compositors: []string{"hyprland", "niri"},
+ Distro: []string{"arch"},
+ Screenshot: "https://example.com/screenshot.png",
+ }
+
+ createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
+ createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
+
+ err := registry.loadPlugins()
+ assert.NoError(t, err)
+ assert.Len(t, registry.plugins, 2)
+
+ assert.Equal(t, "TestPlugin1", registry.plugins[0].Name)
+ assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
+ assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
+ assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
+ })
+
+ t.Run("skips non-json files", func(t *testing.T) {
+ registry, fs, tmpDir := setupTestRegistry(t)
+
+ pluginsDir := filepath.Join(tmpDir, "plugins")
+ err := fs.MkdirAll(pluginsDir, 0755)
+ require.NoError(t, err)
+
+ err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("# Test"), 0644)
+ require.NoError(t, err)
+
+ plugin := Plugin{
+ Name: "ValidPlugin",
+ Capabilities: []string{"test"},
+ Category: "test",
+ Repo: "https://github.com/test/test",
+ Author: "Test",
+ Description: "Test",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+ createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
+
+ err = registry.loadPlugins()
+ assert.NoError(t, err)
+ assert.Len(t, registry.plugins, 1)
+ assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
+ })
+
+ t.Run("skips directories", func(t *testing.T) {
+ registry, fs, tmpDir := setupTestRegistry(t)
+
+ pluginsDir := filepath.Join(tmpDir, "plugins")
+ err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0755)
+ require.NoError(t, err)
+
+ plugin := Plugin{
+ Name: "ValidPlugin",
+ Capabilities: []string{"test"},
+ Category: "test",
+ Repo: "https://github.com/test/test",
+ Author: "Test",
+ Description: "Test",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+ createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
+
+ err = registry.loadPlugins()
+ assert.NoError(t, err)
+ assert.Len(t, registry.plugins, 1)
+ })
+
+ t.Run("skips invalid json files", func(t *testing.T) {
+ registry, fs, tmpDir := setupTestRegistry(t)
+
+ pluginsDir := filepath.Join(tmpDir, "plugins")
+ err := fs.MkdirAll(pluginsDir, 0755)
+ require.NoError(t, err)
+
+ err = afero.WriteFile(fs, filepath.Join(pluginsDir, "invalid.json"), []byte("{invalid json}"), 0644)
+ require.NoError(t, err)
+
+ plugin := Plugin{
+ Name: "ValidPlugin",
+ Capabilities: []string{"test"},
+ Category: "test",
+ Repo: "https://github.com/test/test",
+ Author: "Test",
+ Description: "Test",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+ createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
+
+ err = registry.loadPlugins()
+ assert.NoError(t, err)
+ assert.Len(t, registry.plugins, 1)
+ assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
+ })
+
+ t.Run("returns error when plugins directory missing", func(t *testing.T) {
+ registry, _, _ := setupTestRegistry(t)
+
+ err := registry.loadPlugins()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to read plugins directory")
+ })
+}
+
+func TestList(t *testing.T) {
+ t.Run("returns cached plugins if available", func(t *testing.T) {
+ registry, _, _ := setupTestRegistry(t)
+
+ plugin := Plugin{
+ Name: "CachedPlugin",
+ Capabilities: []string{"test"},
+ Category: "test",
+ Repo: "https://github.com/test/test",
+ Author: "Test",
+ Description: "Test",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+
+ registry.plugins = []Plugin{plugin}
+
+ plugins, err := registry.List()
+ assert.NoError(t, err)
+ assert.Len(t, plugins, 1)
+ assert.Equal(t, "CachedPlugin", plugins[0].Name)
+ })
+
+ t.Run("updates and loads plugins when cache is empty", func(t *testing.T) {
+ registry, fs, _ := setupTestRegistry(t)
+
+ plugin := Plugin{
+ Name: "NewPlugin",
+ Capabilities: []string{"test"},
+ Category: "test",
+ Repo: "https://github.com/test/test",
+ Author: "Test",
+ Description: "Test",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+
+ mockGit := &mockGitClient{
+ cloneFunc: func(path string, url string) error {
+ createTestPlugin(t, fs, path, "plugin.json", plugin)
+ return nil
+ },
+ }
+ registry.git = mockGit
+
+ plugins, err := registry.List()
+ assert.NoError(t, err)
+ assert.Len(t, plugins, 1)
+ assert.Equal(t, "NewPlugin", plugins[0].Name)
+ })
+}
+
+func TestUpdate(t *testing.T) {
+ t.Run("clones repository when cache doesn't exist", func(t *testing.T) {
+ registry, fs, tmpDir := setupTestRegistry(t)
+
+ plugin := Plugin{
+ Name: "RepoPlugin",
+ Capabilities: []string{"test"},
+ Category: "test",
+ Repo: "https://github.com/test/test",
+ Author: "Test",
+ Description: "Test",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+
+ cloneCalled := false
+ mockGit := &mockGitClient{
+ cloneFunc: func(path string, url string) error {
+ cloneCalled = true
+ assert.Equal(t, registryRepo, url)
+ assert.Equal(t, tmpDir, path)
+ createTestPlugin(t, fs, path, "plugin.json", plugin)
+ return nil
+ },
+ }
+ registry.git = mockGit
+
+ err := registry.Update()
+ assert.NoError(t, err)
+ assert.True(t, cloneCalled)
+ assert.Len(t, registry.plugins, 1)
+ assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
+ })
+
+ t.Run("pulls updates when cache exists", func(t *testing.T) {
+ registry, fs, tmpDir := setupTestRegistry(t)
+
+ plugin := Plugin{
+ Name: "UpdatedPlugin",
+ Capabilities: []string{"test"},
+ Category: "test",
+ Repo: "https://github.com/test/test",
+ Author: "Test",
+ Description: "Test",
+ Compositors: []string{"niri"},
+ Distro: []string{"any"},
+ }
+
+ err := fs.MkdirAll(tmpDir, 0755)
+ require.NoError(t, err)
+
+ pullCalled := false
+ mockGit := &mockGitClient{
+ pullFunc: func(path string) error {
+ pullCalled = true
+ assert.Equal(t, tmpDir, path)
+ createTestPlugin(t, fs, path, "plugin.json", plugin)
+ return nil
+ },
+ }
+ registry.git = mockGit
+
+ err = registry.Update()
+ assert.NoError(t, err)
+ assert.True(t, pullCalled)
+ assert.Len(t, registry.plugins, 1)
+ assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
+ })
+}
diff --git a/backend/internal/plugins/search.go b/backend/internal/plugins/search.go
new file mode 100644
index 00000000..0505180d
--- /dev/null
+++ b/backend/internal/plugins/search.go
@@ -0,0 +1,105 @@
+package plugins
+
+import (
+ "sort"
+ "strings"
+)
+
+func FuzzySearch(query string, plugins []Plugin) []Plugin {
+ if query == "" {
+ return plugins
+ }
+
+ queryLower := strings.ToLower(query)
+ var results []Plugin
+
+ for _, plugin := range plugins {
+ if fuzzyMatch(queryLower, strings.ToLower(plugin.Name)) ||
+ fuzzyMatch(queryLower, strings.ToLower(plugin.Category)) ||
+ fuzzyMatch(queryLower, strings.ToLower(plugin.Description)) ||
+ fuzzyMatch(queryLower, strings.ToLower(plugin.Author)) {
+ results = append(results, plugin)
+ }
+ }
+
+ return results
+}
+
+func fuzzyMatch(query, text string) bool {
+ queryIdx := 0
+ for _, char := range text {
+ if queryIdx < len(query) && char == rune(query[queryIdx]) {
+ queryIdx++
+ }
+ }
+ return queryIdx == len(query)
+}
+
+func FilterByCategory(category string, plugins []Plugin) []Plugin {
+ if category == "" {
+ return plugins
+ }
+
+ var results []Plugin
+ categoryLower := strings.ToLower(category)
+
+ for _, plugin := range plugins {
+ if strings.ToLower(plugin.Category) == categoryLower {
+ results = append(results, plugin)
+ }
+ }
+
+ return results
+}
+
+func FilterByCompositor(compositor string, plugins []Plugin) []Plugin {
+ if compositor == "" {
+ return plugins
+ }
+
+ var results []Plugin
+ compositorLower := strings.ToLower(compositor)
+
+ for _, plugin := range plugins {
+ for _, comp := range plugin.Compositors {
+ if strings.ToLower(comp) == compositorLower {
+ results = append(results, plugin)
+ break
+ }
+ }
+ }
+
+ return results
+}
+
+func FilterByCapability(capability string, plugins []Plugin) []Plugin {
+ if capability == "" {
+ return plugins
+ }
+
+ var results []Plugin
+ capabilityLower := strings.ToLower(capability)
+
+ for _, plugin := range plugins {
+ for _, cap := range plugin.Capabilities {
+ if strings.ToLower(cap) == capabilityLower {
+ results = append(results, plugin)
+ break
+ }
+ }
+ }
+
+ return results
+}
+
+func SortByFirstParty(plugins []Plugin) []Plugin {
+ sort.SliceStable(plugins, func(i, j int) bool {
+ isFirstPartyI := strings.HasPrefix(plugins[i].Repo, "https://github.com/AvengeMedia")
+ isFirstPartyJ := strings.HasPrefix(plugins[j].Repo, "https://github.com/AvengeMedia")
+ if isFirstPartyI != isFirstPartyJ {
+ return isFirstPartyI
+ }
+ return false
+ })
+ return plugins
+}
diff --git a/backend/internal/proto/dwl_ipc/dwl_ipc.go b/backend/internal/proto/dwl_ipc/dwl_ipc.go
new file mode 100644
index 00000000..65b590dc
--- /dev/null
+++ b/backend/internal/proto/dwl_ipc/dwl_ipc.go
@@ -0,0 +1,491 @@
+// Generated by go-wayland-scanner
+// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
+// XML file : internal/proto/xml/dwl-ipc-unstable-v2.xml
+//
+// dwl_ipc_unstable_v2 Protocol Copyright:
+
+package dwl_ipc
+
+import "github.com/yaslama/go-wayland/wayland/client"
+
+// ZdwlIpcManagerV2InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZdwlIpcManagerV2InterfaceName = "zdwl_ipc_manager_v2"
+
+// ZdwlIpcManagerV2 : manage dwl state
+//
+// This interface is exposed as a global in wl_registry.
+//
+// Clients can use this interface to get a dwl_ipc_output.
+// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
+// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
+type ZdwlIpcManagerV2 struct {
+ client.BaseProxy
+ tagsHandler ZdwlIpcManagerV2TagsHandlerFunc
+ layoutHandler ZdwlIpcManagerV2LayoutHandlerFunc
+}
+
+// NewZdwlIpcManagerV2 : manage dwl state
+//
+// This interface is exposed as a global in wl_registry.
+//
+// Clients can use this interface to get a dwl_ipc_output.
+// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
+// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
+func NewZdwlIpcManagerV2(ctx *client.Context) *ZdwlIpcManagerV2 {
+ zdwlIpcManagerV2 := &ZdwlIpcManagerV2{}
+ ctx.Register(zdwlIpcManagerV2)
+ return zdwlIpcManagerV2
+}
+
+// Release : release dwl_ipc_manager
+//
+// Indicates that the client will not the dwl_ipc_manager object anymore.
+// Objects created through this instance are not affected.
+func (i *ZdwlIpcManagerV2) Release() error {
+ defer i.Context().Unregister(i)
+ const opcode = 0
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// GetOutput : get a dwl_ipc_outout for a wl_output
+//
+// Get a dwl_ipc_outout for the specified wl_output.
+func (i *ZdwlIpcManagerV2) GetOutput(output *client.Output) (*ZdwlIpcOutputV2, error) {
+ id := NewZdwlIpcOutputV2(i.Context())
+ const opcode = 1
+ const _reqBufLen = 8 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], id.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], output.ID())
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return id, err
+}
+
+// ZdwlIpcManagerV2TagsEvent : Announces tag amount
+//
+// This event is sent after binding.
+// A roundtrip after binding guarantees the client recieved all tags.
+type ZdwlIpcManagerV2TagsEvent struct {
+ Amount uint32
+}
+type ZdwlIpcManagerV2TagsHandlerFunc func(ZdwlIpcManagerV2TagsEvent)
+
+// SetTagsHandler : sets handler for ZdwlIpcManagerV2TagsEvent
+func (i *ZdwlIpcManagerV2) SetTagsHandler(f ZdwlIpcManagerV2TagsHandlerFunc) {
+ i.tagsHandler = f
+}
+
+// ZdwlIpcManagerV2LayoutEvent : Announces a layout
+//
+// This event is sent after binding.
+// A roundtrip after binding guarantees the client recieved all layouts.
+type ZdwlIpcManagerV2LayoutEvent struct {
+ Name string
+}
+type ZdwlIpcManagerV2LayoutHandlerFunc func(ZdwlIpcManagerV2LayoutEvent)
+
+// SetLayoutHandler : sets handler for ZdwlIpcManagerV2LayoutEvent
+func (i *ZdwlIpcManagerV2) SetLayoutHandler(f ZdwlIpcManagerV2LayoutHandlerFunc) {
+ i.layoutHandler = f
+}
+
+func (i *ZdwlIpcManagerV2) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.tagsHandler == nil {
+ return
+ }
+ var e ZdwlIpcManagerV2TagsEvent
+ l := 0
+ e.Amount = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.tagsHandler(e)
+ case 1:
+ if i.layoutHandler == nil {
+ return
+ }
+ var e ZdwlIpcManagerV2LayoutEvent
+ l := 0
+ nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Name = client.String(data[l : l+nameLen])
+ l += nameLen
+
+ i.layoutHandler(e)
+ }
+}
+
+// ZdwlIpcOutputV2InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZdwlIpcOutputV2InterfaceName = "zdwl_ipc_output_v2"
+
+// ZdwlIpcOutputV2 : control dwl output
+//
+// Observe and control a dwl output.
+//
+// Events are double-buffered:
+// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
+//
+// Request are not double-buffered:
+// The compositor will update immediately upon request.
+type ZdwlIpcOutputV2 struct {
+ client.BaseProxy
+ toggleVisibilityHandler ZdwlIpcOutputV2ToggleVisibilityHandlerFunc
+ activeHandler ZdwlIpcOutputV2ActiveHandlerFunc
+ tagHandler ZdwlIpcOutputV2TagHandlerFunc
+ layoutHandler ZdwlIpcOutputV2LayoutHandlerFunc
+ titleHandler ZdwlIpcOutputV2TitleHandlerFunc
+ appidHandler ZdwlIpcOutputV2AppidHandlerFunc
+ layoutSymbolHandler ZdwlIpcOutputV2LayoutSymbolHandlerFunc
+ frameHandler ZdwlIpcOutputV2FrameHandlerFunc
+}
+
+// NewZdwlIpcOutputV2 : control dwl output
+//
+// Observe and control a dwl output.
+//
+// Events are double-buffered:
+// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
+//
+// Request are not double-buffered:
+// The compositor will update immediately upon request.
+func NewZdwlIpcOutputV2(ctx *client.Context) *ZdwlIpcOutputV2 {
+ zdwlIpcOutputV2 := &ZdwlIpcOutputV2{}
+ ctx.Register(zdwlIpcOutputV2)
+ return zdwlIpcOutputV2
+}
+
+// Release : release dwl_ipc_outout
+//
+// Indicates to that the client no longer needs this dwl_ipc_output.
+func (i *ZdwlIpcOutputV2) Release() error {
+ defer i.Context().Unregister(i)
+ const opcode = 0
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetTags : Set the active tags of this output
+//
+// tagmask: bitmask of the tags that should be set.
+// toggleTagset: toggle the selected tagset, zero for invalid, nonzero for valid.
+func (i *ZdwlIpcOutputV2) SetTags(tagmask, toggleTagset uint32) error {
+ const opcode = 1
+ const _reqBufLen = 8 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(tagmask))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(toggleTagset))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetClientTags : Set the tags of the focused client.
+//
+// The tags are updated as follows:
+// new_tags = (current_tags AND and_tags) XOR xor_tags
+func (i *ZdwlIpcOutputV2) SetClientTags(andTags, xorTags uint32) error {
+ const opcode = 2
+ const _reqBufLen = 8 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(andTags))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(xorTags))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetLayout : Set the layout of this output
+//
+// index: index of a layout recieved by dwl_ipc_manager.layout
+func (i *ZdwlIpcOutputV2) SetLayout(index uint32) error {
+ const opcode = 3
+ const _reqBufLen = 8 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(index))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+type ZdwlIpcOutputV2TagState uint32
+
+// ZdwlIpcOutputV2TagState :
+const (
+ // ZdwlIpcOutputV2TagStateNone : no state
+ ZdwlIpcOutputV2TagStateNone ZdwlIpcOutputV2TagState = 0
+ // ZdwlIpcOutputV2TagStateActive : tag is active
+ ZdwlIpcOutputV2TagStateActive ZdwlIpcOutputV2TagState = 1
+ // ZdwlIpcOutputV2TagStateUrgent : tag has at least one urgent client
+ ZdwlIpcOutputV2TagStateUrgent ZdwlIpcOutputV2TagState = 2
+)
+
+func (e ZdwlIpcOutputV2TagState) Name() string {
+ switch e {
+ case ZdwlIpcOutputV2TagStateNone:
+ return "none"
+ case ZdwlIpcOutputV2TagStateActive:
+ return "active"
+ case ZdwlIpcOutputV2TagStateUrgent:
+ return "urgent"
+ default:
+ return ""
+ }
+}
+
+func (e ZdwlIpcOutputV2TagState) Value() string {
+ switch e {
+ case ZdwlIpcOutputV2TagStateNone:
+ return "0"
+ case ZdwlIpcOutputV2TagStateActive:
+ return "1"
+ case ZdwlIpcOutputV2TagStateUrgent:
+ return "2"
+ default:
+ return ""
+ }
+}
+
+func (e ZdwlIpcOutputV2TagState) String() string {
+ return e.Name() + "=" + e.Value()
+}
+
+// ZdwlIpcOutputV2ToggleVisibilityEvent : Toggle client visibilty
+//
+// Indicates the client should hide or show themselves.
+// If the client is visible then hide, if hidden then show.
+type ZdwlIpcOutputV2ToggleVisibilityEvent struct{}
+type ZdwlIpcOutputV2ToggleVisibilityHandlerFunc func(ZdwlIpcOutputV2ToggleVisibilityEvent)
+
+// SetToggleVisibilityHandler : sets handler for ZdwlIpcOutputV2ToggleVisibilityEvent
+func (i *ZdwlIpcOutputV2) SetToggleVisibilityHandler(f ZdwlIpcOutputV2ToggleVisibilityHandlerFunc) {
+ i.toggleVisibilityHandler = f
+}
+
+// ZdwlIpcOutputV2ActiveEvent : Update the selected output.
+//
+// Indicates if the output is active. Zero is invalid, nonzero is valid.
+type ZdwlIpcOutputV2ActiveEvent struct {
+ Active uint32
+}
+type ZdwlIpcOutputV2ActiveHandlerFunc func(ZdwlIpcOutputV2ActiveEvent)
+
+// SetActiveHandler : sets handler for ZdwlIpcOutputV2ActiveEvent
+func (i *ZdwlIpcOutputV2) SetActiveHandler(f ZdwlIpcOutputV2ActiveHandlerFunc) {
+ i.activeHandler = f
+}
+
+// ZdwlIpcOutputV2TagEvent : Update the state of a tag.
+//
+// Indicates that a tag has been updated.
+type ZdwlIpcOutputV2TagEvent struct {
+ Tag uint32
+ State uint32
+ Clients uint32
+ Focused uint32
+}
+type ZdwlIpcOutputV2TagHandlerFunc func(ZdwlIpcOutputV2TagEvent)
+
+// SetTagHandler : sets handler for ZdwlIpcOutputV2TagEvent
+func (i *ZdwlIpcOutputV2) SetTagHandler(f ZdwlIpcOutputV2TagHandlerFunc) {
+ i.tagHandler = f
+}
+
+// ZdwlIpcOutputV2LayoutEvent : Update the layout.
+//
+// Indicates a new layout is selected.
+type ZdwlIpcOutputV2LayoutEvent struct {
+ Layout uint32
+}
+type ZdwlIpcOutputV2LayoutHandlerFunc func(ZdwlIpcOutputV2LayoutEvent)
+
+// SetLayoutHandler : sets handler for ZdwlIpcOutputV2LayoutEvent
+func (i *ZdwlIpcOutputV2) SetLayoutHandler(f ZdwlIpcOutputV2LayoutHandlerFunc) {
+ i.layoutHandler = f
+}
+
+// ZdwlIpcOutputV2TitleEvent : Update the title.
+//
+// Indicates the title has changed.
+type ZdwlIpcOutputV2TitleEvent struct {
+ Title string
+}
+type ZdwlIpcOutputV2TitleHandlerFunc func(ZdwlIpcOutputV2TitleEvent)
+
+// SetTitleHandler : sets handler for ZdwlIpcOutputV2TitleEvent
+func (i *ZdwlIpcOutputV2) SetTitleHandler(f ZdwlIpcOutputV2TitleHandlerFunc) {
+ i.titleHandler = f
+}
+
+// ZdwlIpcOutputV2AppidEvent : Update the appid.
+//
+// Indicates the appid has changed.
+type ZdwlIpcOutputV2AppidEvent struct {
+ Appid string
+}
+type ZdwlIpcOutputV2AppidHandlerFunc func(ZdwlIpcOutputV2AppidEvent)
+
+// SetAppidHandler : sets handler for ZdwlIpcOutputV2AppidEvent
+func (i *ZdwlIpcOutputV2) SetAppidHandler(f ZdwlIpcOutputV2AppidHandlerFunc) {
+ i.appidHandler = f
+}
+
+// ZdwlIpcOutputV2LayoutSymbolEvent : Update the current layout symbol
+//
+// Indicates the layout has changed. Since layout symbols are dynamic.
+// As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying.
+// You can ignore the zdwl_ipc_output.layout event.
+type ZdwlIpcOutputV2LayoutSymbolEvent struct {
+ Layout string
+}
+type ZdwlIpcOutputV2LayoutSymbolHandlerFunc func(ZdwlIpcOutputV2LayoutSymbolEvent)
+
+// SetLayoutSymbolHandler : sets handler for ZdwlIpcOutputV2LayoutSymbolEvent
+func (i *ZdwlIpcOutputV2) SetLayoutSymbolHandler(f ZdwlIpcOutputV2LayoutSymbolHandlerFunc) {
+ i.layoutSymbolHandler = f
+}
+
+// ZdwlIpcOutputV2FrameEvent : The update sequence is done.
+//
+// Indicates that a sequence of status updates have finished and the client should redraw.
+type ZdwlIpcOutputV2FrameEvent struct{}
+type ZdwlIpcOutputV2FrameHandlerFunc func(ZdwlIpcOutputV2FrameEvent)
+
+// SetFrameHandler : sets handler for ZdwlIpcOutputV2FrameEvent
+func (i *ZdwlIpcOutputV2) SetFrameHandler(f ZdwlIpcOutputV2FrameHandlerFunc) {
+ i.frameHandler = f
+}
+
+func (i *ZdwlIpcOutputV2) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.toggleVisibilityHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2ToggleVisibilityEvent
+
+ i.toggleVisibilityHandler(e)
+ case 1:
+ if i.activeHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2ActiveEvent
+ l := 0
+ e.Active = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.activeHandler(e)
+ case 2:
+ if i.tagHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2TagEvent
+ l := 0
+ e.Tag = client.Uint32(data[l : l+4])
+ l += 4
+ e.State = client.Uint32(data[l : l+4])
+ l += 4
+ e.Clients = client.Uint32(data[l : l+4])
+ l += 4
+ e.Focused = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.tagHandler(e)
+ case 3:
+ if i.layoutHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2LayoutEvent
+ l := 0
+ e.Layout = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.layoutHandler(e)
+ case 4:
+ if i.titleHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2TitleEvent
+ l := 0
+ titleLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Title = client.String(data[l : l+titleLen])
+ l += titleLen
+
+ i.titleHandler(e)
+ case 5:
+ if i.appidHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2AppidEvent
+ l := 0
+ appidLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Appid = client.String(data[l : l+appidLen])
+ l += appidLen
+
+ i.appidHandler(e)
+ case 6:
+ if i.layoutSymbolHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2LayoutSymbolEvent
+ l := 0
+ layoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Layout = client.String(data[l : l+layoutLen])
+ l += layoutLen
+
+ i.layoutSymbolHandler(e)
+ case 7:
+ if i.frameHandler == nil {
+ return
+ }
+ var e ZdwlIpcOutputV2FrameEvent
+
+ i.frameHandler(e)
+ }
+}
diff --git a/backend/internal/proto/ext_workspace/workspace.go b/backend/internal/proto/ext_workspace/workspace.go
new file mode 100644
index 00000000..861400fd
--- /dev/null
+++ b/backend/internal/proto/ext_workspace/workspace.go
@@ -0,0 +1,1038 @@
+// Generated by go-wayland-scanner
+// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
+// XML file : ext-workspace-v1.xml
+//
+// ext_workspace_v1 Protocol Copyright:
+//
+// Copyright © 2019 Christopher Billington
+// Copyright © 2020 Ilia Bozhinov
+// Copyright © 2022 Victoria Brekenfeld
+//
+// Permission to use, copy, modify, distribute, and sell this
+// software and its documentation for any purpose is hereby granted
+// without fee, provided that the above copyright notice appear in
+// all copies and that both that copyright notice and this permission
+// notice appear in supporting documentation, and that the name of
+// the copyright holders not be used in advertising or publicity
+// pertaining to distribution of the software without specific,
+// written prior permission. The copyright holders make no
+// representations about the suitability of this software for any
+// purpose. It is provided "as is" without express or implied
+// warranty.
+//
+// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+// THIS SOFTWARE.
+
+package ext_workspace
+
+import (
+ "reflect"
+ "unsafe"
+
+ "github.com/yaslama/go-wayland/wayland/client"
+)
+
+// registerServerProxy registers a proxy with a server-assigned ID.
+// This is necessary because go-wayland-scanner doesn't properly handle new_id arguments in events.
+// In requests (like DWL), the client creates the ID via NewXxx(ctx) which calls ctx.Register().
+// In events (like ext-workspace), the server creates the ID and sends it, requiring manual registration.
+// The Context.objects map is private with no public API for server IDs, requiring reflection.
+func registerServerProxy(ctx *client.Context, proxy client.Proxy, serverID uint32) {
+ defer func() {
+ if r := recover(); r != nil {
+ return
+ }
+ }()
+
+ ctxVal := reflect.ValueOf(ctx)
+ if ctxVal.Kind() != reflect.Ptr || ctxVal.IsNil() {
+ return
+ }
+
+ ctxElem := ctxVal.Elem()
+ objectsField := ctxElem.FieldByName("objects")
+ if !objectsField.IsValid() {
+ return
+ }
+
+ objectsMap := reflect.NewAt(objectsField.Type(), unsafe.Pointer(objectsField.UnsafeAddr())).Elem()
+ objectsMap.SetMapIndex(reflect.ValueOf(serverID), reflect.ValueOf(proxy))
+}
+
+// ExtWorkspaceManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ExtWorkspaceManagerV1InterfaceName = "ext_workspace_manager_v1"
+
+// ExtWorkspaceManagerV1 : list and control workspaces
+//
+// Workspaces, also called virtual desktops, are groups of surfaces. A
+// compositor with a concept of workspaces may only show some such groups of
+// surfaces (those of 'active' workspaces) at a time. 'Activating' a
+// workspace is a request for the compositor to display that workspace's
+// surfaces as normal, whereas the compositor may hide or otherwise
+// de-emphasise surfaces that are associated only with 'inactive' workspaces.
+// Workspaces are grouped by which sets of outputs they correspond to, and
+// may contain surfaces only from those outputs. In this way, it is possible
+// for each output to have its own set of workspaces, or for all outputs (or
+// any other arbitrary grouping) to share workspaces. Compositors may
+// optionally conceptually arrange each group of workspaces in an
+// N-dimensional grid.
+//
+// The purpose of this protocol is to enable the creation of taskbars and
+// docks by providing them with a list of workspaces and their properties,
+// and allowing them to activate and deactivate workspaces.
+//
+// After a client binds the ext_workspace_manager_v1, each workspace will be
+// sent via the workspace event.
+type ExtWorkspaceManagerV1 struct {
+ client.BaseProxy
+ workspaceGroupHandler ExtWorkspaceManagerV1WorkspaceGroupHandlerFunc
+ workspaceHandler ExtWorkspaceManagerV1WorkspaceHandlerFunc
+ doneHandler ExtWorkspaceManagerV1DoneHandlerFunc
+ finishedHandler ExtWorkspaceManagerV1FinishedHandlerFunc
+}
+
+// NewExtWorkspaceManagerV1 : list and control workspaces
+//
+// Workspaces, also called virtual desktops, are groups of surfaces. A
+// compositor with a concept of workspaces may only show some such groups of
+// surfaces (those of 'active' workspaces) at a time. 'Activating' a
+// workspace is a request for the compositor to display that workspace's
+// surfaces as normal, whereas the compositor may hide or otherwise
+// de-emphasise surfaces that are associated only with 'inactive' workspaces.
+// Workspaces are grouped by which sets of outputs they correspond to, and
+// may contain surfaces only from those outputs. In this way, it is possible
+// for each output to have its own set of workspaces, or for all outputs (or
+// any other arbitrary grouping) to share workspaces. Compositors may
+// optionally conceptually arrange each group of workspaces in an
+// N-dimensional grid.
+//
+// The purpose of this protocol is to enable the creation of taskbars and
+// docks by providing them with a list of workspaces and their properties,
+// and allowing them to activate and deactivate workspaces.
+//
+// After a client binds the ext_workspace_manager_v1, each workspace will be
+// sent via the workspace event.
+func NewExtWorkspaceManagerV1(ctx *client.Context) *ExtWorkspaceManagerV1 {
+ extWorkspaceManagerV1 := &ExtWorkspaceManagerV1{}
+ ctx.Register(extWorkspaceManagerV1)
+ return extWorkspaceManagerV1
+}
+
+// Commit : all requests about the workspaces have been sent
+//
+// The client must send this request after it has finished sending other
+// requests. The compositor must process a series of requests preceding a
+// commit request atomically.
+//
+// This allows changes to the workspace properties to be seen as atomic,
+// even if they happen via multiple events, and even if they involve
+// multiple ext_workspace_handle_v1 objects, for example, deactivating one
+// workspace and activating another.
+func (i *ExtWorkspaceManagerV1) Commit() error {
+ const opcode = 0
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Stop : stop sending events
+//
+// Indicates the client no longer wishes to receive events for new
+// workspace groups. However the compositor may emit further workspace
+// events, until the finished event is emitted. The compositor is expected
+// to send the finished event eventually once the stop request has been processed.
+//
+// The client must not send any requests after this one, doing so will raise a wl_display
+// invalid_object error.
+func (i *ExtWorkspaceManagerV1) Stop() error {
+ const opcode = 1
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+func (i *ExtWorkspaceManagerV1) Destroy() error {
+ i.Context().Unregister(i)
+ return nil
+}
+
+// ExtWorkspaceManagerV1WorkspaceGroupEvent : a workspace group has been created
+//
+// This event is emitted whenever a new workspace group has been created.
+//
+// All initial details of the workspace group (outputs) will be
+// sent immediately after this event via the corresponding events in
+// ext_workspace_group_handle_v1 and ext_workspace_handle_v1.
+type ExtWorkspaceManagerV1WorkspaceGroupEvent struct {
+ WorkspaceGroup *ExtWorkspaceGroupHandleV1
+}
+type ExtWorkspaceManagerV1WorkspaceGroupHandlerFunc func(ExtWorkspaceManagerV1WorkspaceGroupEvent)
+
+// SetWorkspaceGroupHandler : sets handler for ExtWorkspaceManagerV1WorkspaceGroupEvent
+func (i *ExtWorkspaceManagerV1) SetWorkspaceGroupHandler(f ExtWorkspaceManagerV1WorkspaceGroupHandlerFunc) {
+ i.workspaceGroupHandler = f
+}
+
+// ExtWorkspaceManagerV1WorkspaceEvent : workspace has been created
+//
+// This event is emitted whenever a new workspace has been created.
+//
+// All initial details of the workspace (name, coordinates, state) will
+// be sent immediately after this event via the corresponding events in
+// ext_workspace_handle_v1.
+//
+// Workspaces start off unassigned to any workspace group.
+type ExtWorkspaceManagerV1WorkspaceEvent struct {
+ Workspace *ExtWorkspaceHandleV1
+}
+type ExtWorkspaceManagerV1WorkspaceHandlerFunc func(ExtWorkspaceManagerV1WorkspaceEvent)
+
+// SetWorkspaceHandler : sets handler for ExtWorkspaceManagerV1WorkspaceEvent
+func (i *ExtWorkspaceManagerV1) SetWorkspaceHandler(f ExtWorkspaceManagerV1WorkspaceHandlerFunc) {
+ i.workspaceHandler = f
+}
+
+// ExtWorkspaceManagerV1DoneEvent : all information about the workspaces and workspace groups has been sent
+//
+// This event is sent after all changes in all workspaces and workspace groups have been
+// sent.
+//
+// This allows changes to one or more ext_workspace_group_handle_v1
+// properties and ext_workspace_handle_v1 properties
+// to be seen as atomic, even if they happen via multiple events.
+// In particular, an output moving from one workspace group to
+// another sends an output_enter event and an output_leave event to the two
+// ext_workspace_group_handle_v1 objects in question. The compositor sends
+// the done event only after updating the output information in both
+// workspace groups.
+type ExtWorkspaceManagerV1DoneEvent struct{}
+type ExtWorkspaceManagerV1DoneHandlerFunc func(ExtWorkspaceManagerV1DoneEvent)
+
+// SetDoneHandler : sets handler for ExtWorkspaceManagerV1DoneEvent
+func (i *ExtWorkspaceManagerV1) SetDoneHandler(f ExtWorkspaceManagerV1DoneHandlerFunc) {
+ i.doneHandler = f
+}
+
+// ExtWorkspaceManagerV1FinishedEvent : the compositor has finished with the workspace_manager
+//
+// This event indicates that the compositor is done sending events to the
+// ext_workspace_manager_v1. The server will destroy the object
+// immediately after sending this request.
+type ExtWorkspaceManagerV1FinishedEvent struct{}
+type ExtWorkspaceManagerV1FinishedHandlerFunc func(ExtWorkspaceManagerV1FinishedEvent)
+
+// SetFinishedHandler : sets handler for ExtWorkspaceManagerV1FinishedEvent
+func (i *ExtWorkspaceManagerV1) SetFinishedHandler(f ExtWorkspaceManagerV1FinishedHandlerFunc) {
+ i.finishedHandler = f
+}
+
+func (i *ExtWorkspaceManagerV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.workspaceGroupHandler == nil {
+ return
+ }
+ var e ExtWorkspaceManagerV1WorkspaceGroupEvent
+ l := 0
+ objectID := client.Uint32(data[l : l+4])
+ proxy := i.Context().GetProxy(objectID)
+ if proxy != nil {
+ e.WorkspaceGroup = proxy.(*ExtWorkspaceGroupHandleV1)
+ } else {
+ groupHandle := &ExtWorkspaceGroupHandleV1{}
+ groupHandle.SetContext(i.Context())
+ groupHandle.SetID(objectID)
+ registerServerProxy(i.Context(), groupHandle, objectID)
+ e.WorkspaceGroup = groupHandle
+ }
+ l += 4
+
+ i.workspaceGroupHandler(e)
+ case 1:
+ if i.workspaceHandler == nil {
+ return
+ }
+ var e ExtWorkspaceManagerV1WorkspaceEvent
+ l := 0
+ objectID := client.Uint32(data[l : l+4])
+ proxy := i.Context().GetProxy(objectID)
+ if proxy != nil {
+ e.Workspace = proxy.(*ExtWorkspaceHandleV1)
+ } else {
+ wsHandle := &ExtWorkspaceHandleV1{}
+ wsHandle.SetContext(i.Context())
+ wsHandle.SetID(objectID)
+ registerServerProxy(i.Context(), wsHandle, objectID)
+ e.Workspace = wsHandle
+ }
+ l += 4
+
+ i.workspaceHandler(e)
+ case 2:
+ if i.doneHandler == nil {
+ return
+ }
+ var e ExtWorkspaceManagerV1DoneEvent
+
+ i.doneHandler(e)
+ case 3:
+ if i.finishedHandler == nil {
+ return
+ }
+ var e ExtWorkspaceManagerV1FinishedEvent
+
+ i.finishedHandler(e)
+ }
+}
+
+// ExtWorkspaceGroupHandleV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ExtWorkspaceGroupHandleV1InterfaceName = "ext_workspace_group_handle_v1"
+
+// ExtWorkspaceGroupHandleV1 : a workspace group assigned to a set of outputs
+//
+// A ext_workspace_group_handle_v1 object represents a workspace group
+// that is assigned a set of outputs and contains a number of workspaces.
+//
+// The set of outputs assigned to the workspace group is conveyed to the client via
+// output_enter and output_leave events, and its workspaces are conveyed with
+// workspace events.
+//
+// For example, a compositor which has a set of workspaces for each output may
+// advertise a workspace group (and its workspaces) per output, whereas a compositor
+// where a workspace spans all outputs may advertise a single workspace group for all
+// outputs.
+type ExtWorkspaceGroupHandleV1 struct {
+ client.BaseProxy
+ capabilitiesHandler ExtWorkspaceGroupHandleV1CapabilitiesHandlerFunc
+ outputEnterHandler ExtWorkspaceGroupHandleV1OutputEnterHandlerFunc
+ outputLeaveHandler ExtWorkspaceGroupHandleV1OutputLeaveHandlerFunc
+ workspaceEnterHandler ExtWorkspaceGroupHandleV1WorkspaceEnterHandlerFunc
+ workspaceLeaveHandler ExtWorkspaceGroupHandleV1WorkspaceLeaveHandlerFunc
+ removedHandler ExtWorkspaceGroupHandleV1RemovedHandlerFunc
+}
+
+// NewExtWorkspaceGroupHandleV1 : a workspace group assigned to a set of outputs
+//
+// A ext_workspace_group_handle_v1 object represents a workspace group
+// that is assigned a set of outputs and contains a number of workspaces.
+//
+// The set of outputs assigned to the workspace group is conveyed to the client via
+// output_enter and output_leave events, and its workspaces are conveyed with
+// workspace events.
+//
+// For example, a compositor which has a set of workspaces for each output may
+// advertise a workspace group (and its workspaces) per output, whereas a compositor
+// where a workspace spans all outputs may advertise a single workspace group for all
+// outputs.
+func NewExtWorkspaceGroupHandleV1(ctx *client.Context) *ExtWorkspaceGroupHandleV1 {
+ extWorkspaceGroupHandleV1 := &ExtWorkspaceGroupHandleV1{}
+ ctx.Register(extWorkspaceGroupHandleV1)
+ return extWorkspaceGroupHandleV1
+}
+
+// CreateWorkspace : create a new workspace
+//
+// Request that the compositor create a new workspace with the given name
+// and assign it to this group.
+//
+// There is no guarantee that the compositor will create a new workspace,
+// or that the created workspace will have the provided name.
+func (i *ExtWorkspaceGroupHandleV1) CreateWorkspace(workspace string) error {
+ const opcode = 0
+ workspaceLen := client.PaddedLen(len(workspace) + 1)
+ _reqBufLen := 8 + (4 + workspaceLen)
+ _reqBuf := make([]byte, _reqBufLen)
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutString(_reqBuf[l:l+(4+workspaceLen)], workspace)
+ l += (4 + workspaceLen)
+ err := i.Context().WriteMsg(_reqBuf, nil)
+ return err
+}
+
+// Destroy : destroy the ext_workspace_group_handle_v1 object
+//
+// Destroys the ext_workspace_group_handle_v1 object.
+//
+// This request should be send either when the client does not want to
+// use the workspace group object any more or after the removed event to finalize
+// the destruction of the object.
+func (i *ExtWorkspaceGroupHandleV1) Destroy() error {
+ defer i.Context().Unregister(i)
+ const opcode = 1
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+type ExtWorkspaceGroupHandleV1GroupCapabilities uint32
+
+// ExtWorkspaceGroupHandleV1GroupCapabilities :
+const (
+ // ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace : create_workspace request is available
+ ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace ExtWorkspaceGroupHandleV1GroupCapabilities = 1
+)
+
+func (e ExtWorkspaceGroupHandleV1GroupCapabilities) Name() string {
+ switch e {
+ case ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace:
+ return "create_workspace"
+ default:
+ return ""
+ }
+}
+
+func (e ExtWorkspaceGroupHandleV1GroupCapabilities) Value() string {
+ switch e {
+ case ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace:
+ return "1"
+ default:
+ return ""
+ }
+}
+
+func (e ExtWorkspaceGroupHandleV1GroupCapabilities) String() string {
+ return e.Name() + "=" + e.Value()
+}
+
+// ExtWorkspaceGroupHandleV1CapabilitiesEvent : compositor capabilities
+//
+// This event advertises the capabilities supported by the compositor. If
+// a capability isn't supported, clients should hide or disable the UI
+// elements that expose this functionality. For instance, if the
+// compositor doesn't advertise support for creating workspaces, a button
+// triggering the create_workspace request should not be displayed.
+//
+// The compositor will ignore requests it doesn't support. For instance,
+// a compositor which doesn't advertise support for creating workspaces will ignore
+// create_workspace requests.
+//
+// Compositors must send this event once after creation of an
+// ext_workspace_group_handle_v1. When the capabilities change, compositors
+// must send this event again.
+type ExtWorkspaceGroupHandleV1CapabilitiesEvent struct {
+ Capabilities uint32
+}
+type ExtWorkspaceGroupHandleV1CapabilitiesHandlerFunc func(ExtWorkspaceGroupHandleV1CapabilitiesEvent)
+
+// SetCapabilitiesHandler : sets handler for ExtWorkspaceGroupHandleV1CapabilitiesEvent
+func (i *ExtWorkspaceGroupHandleV1) SetCapabilitiesHandler(f ExtWorkspaceGroupHandleV1CapabilitiesHandlerFunc) {
+ i.capabilitiesHandler = f
+}
+
+// ExtWorkspaceGroupHandleV1OutputEnterEvent : output assigned to workspace group
+//
+// This event is emitted whenever an output is assigned to the workspace
+// group or a new `wl_output` object is bound by the client, which was already
+// assigned to this workspace_group.
+type ExtWorkspaceGroupHandleV1OutputEnterEvent struct {
+ Output *client.Output
+}
+type ExtWorkspaceGroupHandleV1OutputEnterHandlerFunc func(ExtWorkspaceGroupHandleV1OutputEnterEvent)
+
+// SetOutputEnterHandler : sets handler for ExtWorkspaceGroupHandleV1OutputEnterEvent
+func (i *ExtWorkspaceGroupHandleV1) SetOutputEnterHandler(f ExtWorkspaceGroupHandleV1OutputEnterHandlerFunc) {
+ i.outputEnterHandler = f
+}
+
+// ExtWorkspaceGroupHandleV1OutputLeaveEvent : output removed from workspace group
+//
+// This event is emitted whenever an output is removed from the workspace
+// group.
+type ExtWorkspaceGroupHandleV1OutputLeaveEvent struct {
+ Output *client.Output
+}
+type ExtWorkspaceGroupHandleV1OutputLeaveHandlerFunc func(ExtWorkspaceGroupHandleV1OutputLeaveEvent)
+
+// SetOutputLeaveHandler : sets handler for ExtWorkspaceGroupHandleV1OutputLeaveEvent
+func (i *ExtWorkspaceGroupHandleV1) SetOutputLeaveHandler(f ExtWorkspaceGroupHandleV1OutputLeaveHandlerFunc) {
+ i.outputLeaveHandler = f
+}
+
+// ExtWorkspaceGroupHandleV1WorkspaceEnterEvent : workspace added to workspace group
+//
+// This event is emitted whenever a workspace is assigned to this group.
+// A workspace may only ever be assigned to a single group at a single point
+// in time, but can be re-assigned during it's lifetime.
+type ExtWorkspaceGroupHandleV1WorkspaceEnterEvent struct {
+ Workspace *ExtWorkspaceHandleV1
+}
+type ExtWorkspaceGroupHandleV1WorkspaceEnterHandlerFunc func(ExtWorkspaceGroupHandleV1WorkspaceEnterEvent)
+
+// SetWorkspaceEnterHandler : sets handler for ExtWorkspaceGroupHandleV1WorkspaceEnterEvent
+func (i *ExtWorkspaceGroupHandleV1) SetWorkspaceEnterHandler(f ExtWorkspaceGroupHandleV1WorkspaceEnterHandlerFunc) {
+ i.workspaceEnterHandler = f
+}
+
+// ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent : workspace removed from workspace group
+//
+// This event is emitted whenever a workspace is removed from this group.
+type ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent struct {
+ Workspace *ExtWorkspaceHandleV1
+}
+type ExtWorkspaceGroupHandleV1WorkspaceLeaveHandlerFunc func(ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent)
+
+// SetWorkspaceLeaveHandler : sets handler for ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent
+func (i *ExtWorkspaceGroupHandleV1) SetWorkspaceLeaveHandler(f ExtWorkspaceGroupHandleV1WorkspaceLeaveHandlerFunc) {
+ i.workspaceLeaveHandler = f
+}
+
+// ExtWorkspaceGroupHandleV1RemovedEvent : this workspace group has been removed
+//
+// This event is send when the group associated with the ext_workspace_group_handle_v1
+// has been removed. After sending this request the compositor will immediately consider
+// the object inert. Any requests will be ignored except the destroy request.
+// It is guaranteed there won't be any more events referencing this
+// ext_workspace_group_handle_v1.
+//
+// The compositor must remove all workspaces belonging to a workspace group
+// via a workspace_leave event before removing the workspace group.
+type ExtWorkspaceGroupHandleV1RemovedEvent struct{}
+type ExtWorkspaceGroupHandleV1RemovedHandlerFunc func(ExtWorkspaceGroupHandleV1RemovedEvent)
+
+// SetRemovedHandler : sets handler for ExtWorkspaceGroupHandleV1RemovedEvent
+func (i *ExtWorkspaceGroupHandleV1) SetRemovedHandler(f ExtWorkspaceGroupHandleV1RemovedHandlerFunc) {
+ i.removedHandler = f
+}
+
+func (i *ExtWorkspaceGroupHandleV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.capabilitiesHandler == nil {
+ return
+ }
+ var e ExtWorkspaceGroupHandleV1CapabilitiesEvent
+ l := 0
+ e.Capabilities = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.capabilitiesHandler(e)
+ case 1:
+ if i.outputEnterHandler == nil {
+ return
+ }
+ var e ExtWorkspaceGroupHandleV1OutputEnterEvent
+ l := 0
+ e.Output = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*client.Output)
+ l += 4
+
+ i.outputEnterHandler(e)
+ case 2:
+ if i.outputLeaveHandler == nil {
+ return
+ }
+ var e ExtWorkspaceGroupHandleV1OutputLeaveEvent
+ l := 0
+ e.Output = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*client.Output)
+ l += 4
+
+ i.outputLeaveHandler(e)
+ case 3:
+ if i.workspaceEnterHandler == nil {
+ return
+ }
+ var e ExtWorkspaceGroupHandleV1WorkspaceEnterEvent
+ l := 0
+ e.Workspace = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*ExtWorkspaceHandleV1)
+ l += 4
+
+ i.workspaceEnterHandler(e)
+ case 4:
+ if i.workspaceLeaveHandler == nil {
+ return
+ }
+ var e ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent
+ l := 0
+ e.Workspace = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*ExtWorkspaceHandleV1)
+ l += 4
+
+ i.workspaceLeaveHandler(e)
+ case 5:
+ if i.removedHandler == nil {
+ return
+ }
+ var e ExtWorkspaceGroupHandleV1RemovedEvent
+
+ i.removedHandler(e)
+ }
+}
+
+// ExtWorkspaceHandleV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ExtWorkspaceHandleV1InterfaceName = "ext_workspace_handle_v1"
+
+// ExtWorkspaceHandleV1 : a workspace handing a group of surfaces
+//
+// A ext_workspace_handle_v1 object represents a workspace that handles a
+// group of surfaces.
+//
+// Each workspace has:
+// - a name, conveyed to the client with the name event
+// - potentially an id conveyed with the id event
+// - a list of states, conveyed to the client with the state event
+// - and optionally a set of coordinates, conveyed to the client with the
+// coordinates event
+//
+// The client may request that the compositor activate or deactivate the workspace.
+//
+// Each workspace can belong to only a single workspace group.
+// Depending on the compositor policy, there might be workspaces with
+// the same name in different workspace groups, but these workspaces are still
+// separate (e.g. one of them might be active while the other is not).
+type ExtWorkspaceHandleV1 struct {
+ client.BaseProxy
+ idHandler ExtWorkspaceHandleV1IdHandlerFunc
+ nameHandler ExtWorkspaceHandleV1NameHandlerFunc
+ coordinatesHandler ExtWorkspaceHandleV1CoordinatesHandlerFunc
+ stateHandler ExtWorkspaceHandleV1StateHandlerFunc
+ capabilitiesHandler ExtWorkspaceHandleV1CapabilitiesHandlerFunc
+ removedHandler ExtWorkspaceHandleV1RemovedHandlerFunc
+}
+
+// NewExtWorkspaceHandleV1 : a workspace handing a group of surfaces
+//
+// A ext_workspace_handle_v1 object represents a workspace that handles a
+// group of surfaces.
+//
+// Each workspace has:
+// - a name, conveyed to the client with the name event
+// - potentially an id conveyed with the id event
+// - a list of states, conveyed to the client with the state event
+// - and optionally a set of coordinates, conveyed to the client with the
+// coordinates event
+//
+// The client may request that the compositor activate or deactivate the workspace.
+//
+// Each workspace can belong to only a single workspace group.
+// Depending on the compositor policy, there might be workspaces with
+// the same name in different workspace groups, but these workspaces are still
+// separate (e.g. one of them might be active while the other is not).
+func NewExtWorkspaceHandleV1(ctx *client.Context) *ExtWorkspaceHandleV1 {
+ extWorkspaceHandleV1 := &ExtWorkspaceHandleV1{}
+ ctx.Register(extWorkspaceHandleV1)
+ return extWorkspaceHandleV1
+}
+
+// Destroy : destroy the ext_workspace_handle_v1 object
+//
+// Destroys the ext_workspace_handle_v1 object.
+//
+// This request should be made either when the client does not want to
+// use the workspace object any more or after the remove event to finalize
+// the destruction of the object.
+func (i *ExtWorkspaceHandleV1) Destroy() error {
+ defer i.Context().Unregister(i)
+ const opcode = 0
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Activate : activate the workspace
+//
+// Request that this workspace be activated.
+//
+// There is no guarantee the workspace will be actually activated, and
+// behaviour may be compositor-dependent. For example, activating a
+// workspace may or may not deactivate all other workspaces in the same
+// group.
+func (i *ExtWorkspaceHandleV1) Activate() error {
+ const opcode = 1
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Deactivate : deactivate the workspace
+//
+// Request that this workspace be deactivated.
+//
+// There is no guarantee the workspace will be actually deactivated.
+func (i *ExtWorkspaceHandleV1) Deactivate() error {
+ const opcode = 2
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Assign : assign workspace to group
+//
+// Requests that this workspace is assigned to the given workspace group.
+//
+// There is no guarantee the workspace will be assigned.
+func (i *ExtWorkspaceHandleV1) Assign(workspaceGroup *ExtWorkspaceGroupHandleV1) error {
+ const opcode = 3
+ const _reqBufLen = 8 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], workspaceGroup.ID())
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Remove : remove the workspace
+//
+// Request that this workspace be removed.
+//
+// There is no guarantee the workspace will be actually removed.
+func (i *ExtWorkspaceHandleV1) Remove() error {
+ const opcode = 4
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+type ExtWorkspaceHandleV1State uint32
+
+// ExtWorkspaceHandleV1State : types of states on the workspace
+//
+// The different states that a workspace can have.
+const (
+ // ExtWorkspaceHandleV1StateActive : the workspace is active
+ ExtWorkspaceHandleV1StateActive ExtWorkspaceHandleV1State = 1
+ // ExtWorkspaceHandleV1StateUrgent : the workspace requests attention
+ ExtWorkspaceHandleV1StateUrgent ExtWorkspaceHandleV1State = 2
+ ExtWorkspaceHandleV1StateHidden ExtWorkspaceHandleV1State = 4
+)
+
+func (e ExtWorkspaceHandleV1State) Name() string {
+ switch e {
+ case ExtWorkspaceHandleV1StateActive:
+ return "active"
+ case ExtWorkspaceHandleV1StateUrgent:
+ return "urgent"
+ case ExtWorkspaceHandleV1StateHidden:
+ return "hidden"
+ default:
+ return ""
+ }
+}
+
+func (e ExtWorkspaceHandleV1State) Value() string {
+ switch e {
+ case ExtWorkspaceHandleV1StateActive:
+ return "1"
+ case ExtWorkspaceHandleV1StateUrgent:
+ return "2"
+ case ExtWorkspaceHandleV1StateHidden:
+ return "4"
+ default:
+ return ""
+ }
+}
+
+func (e ExtWorkspaceHandleV1State) String() string {
+ return e.Name() + "=" + e.Value()
+}
+
+type ExtWorkspaceHandleV1WorkspaceCapabilities uint32
+
+// ExtWorkspaceHandleV1WorkspaceCapabilities :
+const (
+ // ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate : activate request is available
+ ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate ExtWorkspaceHandleV1WorkspaceCapabilities = 1
+ // ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate : deactivate request is available
+ ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate ExtWorkspaceHandleV1WorkspaceCapabilities = 2
+ // ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove : remove request is available
+ ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove ExtWorkspaceHandleV1WorkspaceCapabilities = 4
+ // ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign : assign request is available
+ ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign ExtWorkspaceHandleV1WorkspaceCapabilities = 8
+)
+
+func (e ExtWorkspaceHandleV1WorkspaceCapabilities) Name() string {
+ switch e {
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate:
+ return "activate"
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate:
+ return "deactivate"
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove:
+ return "remove"
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign:
+ return "assign"
+ default:
+ return ""
+ }
+}
+
+func (e ExtWorkspaceHandleV1WorkspaceCapabilities) Value() string {
+ switch e {
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate:
+ return "1"
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate:
+ return "2"
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove:
+ return "4"
+ case ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign:
+ return "8"
+ default:
+ return ""
+ }
+}
+
+func (e ExtWorkspaceHandleV1WorkspaceCapabilities) String() string {
+ return e.Name() + "=" + e.Value()
+}
+
+// ExtWorkspaceHandleV1IdEvent : workspace id
+//
+// If this event is emitted, it will be send immediately after the
+// ext_workspace_handle_v1 is created or when an id is assigned to
+// a workspace (at most once during it's lifetime).
+//
+// An id will never change during the lifetime of the `ext_workspace_handle_v1`
+// and is guaranteed to be unique during it's lifetime.
+//
+// Ids are not human-readable and shouldn't be displayed, use `name` for that purpose.
+//
+// Compositors are expected to only send ids for workspaces likely stable across multiple
+// sessions and can be used by clients to store preferences for workspaces. Workspaces without
+// ids should be considered temporary and any data associated with them should be deleted once
+// the respective object is lost.
+type ExtWorkspaceHandleV1IdEvent struct {
+ Id string
+}
+type ExtWorkspaceHandleV1IdHandlerFunc func(ExtWorkspaceHandleV1IdEvent)
+
+// SetIdHandler : sets handler for ExtWorkspaceHandleV1IdEvent
+func (i *ExtWorkspaceHandleV1) SetIdHandler(f ExtWorkspaceHandleV1IdHandlerFunc) {
+ i.idHandler = f
+}
+
+// ExtWorkspaceHandleV1NameEvent : workspace name changed
+//
+// This event is emitted immediately after the ext_workspace_handle_v1 is
+// created and whenever the name of the workspace changes.
+//
+// A name is meant to be human-readable and can be displayed to a user.
+// Unlike the id it is neither stable nor unique.
+type ExtWorkspaceHandleV1NameEvent struct {
+ Name string
+}
+type ExtWorkspaceHandleV1NameHandlerFunc func(ExtWorkspaceHandleV1NameEvent)
+
+// SetNameHandler : sets handler for ExtWorkspaceHandleV1NameEvent
+func (i *ExtWorkspaceHandleV1) SetNameHandler(f ExtWorkspaceHandleV1NameHandlerFunc) {
+ i.nameHandler = f
+}
+
+// ExtWorkspaceHandleV1CoordinatesEvent : workspace coordinates changed
+//
+// This event is used to organize workspaces into an N-dimensional grid
+// within a workspace group, and if supported, is emitted immediately after
+// the ext_workspace_handle_v1 is created and whenever the coordinates of
+// the workspace change. Compositors may not send this event if they do not
+// conceptually arrange workspaces in this way. If compositors simply
+// number workspaces, without any geometric interpretation, they may send
+// 1D coordinates, which clients should not interpret as implying any
+// geometry. Sending an empty array means that the compositor no longer
+// orders the workspace geometrically.
+//
+// Coordinates have an arbitrary number of dimensions N with an uint32
+// position along each dimension. By convention if N > 1, the first
+// dimension is X, the second Y, the third Z, and so on. The compositor may
+// chose to utilize these events for a more novel workspace layout
+// convention, however. No guarantee is made about the grid being filled or
+// bounded; there may be a workspace at coordinate 1 and another at
+// coordinate 1000 and none in between. Within a workspace group, however,
+// workspaces must have unique coordinates of equal dimensionality.
+type ExtWorkspaceHandleV1CoordinatesEvent struct {
+ Coordinates []byte
+}
+type ExtWorkspaceHandleV1CoordinatesHandlerFunc func(ExtWorkspaceHandleV1CoordinatesEvent)
+
+// SetCoordinatesHandler : sets handler for ExtWorkspaceHandleV1CoordinatesEvent
+func (i *ExtWorkspaceHandleV1) SetCoordinatesHandler(f ExtWorkspaceHandleV1CoordinatesHandlerFunc) {
+ i.coordinatesHandler = f
+}
+
+// ExtWorkspaceHandleV1StateEvent : the state of the workspace changed
+//
+// This event is emitted immediately after the ext_workspace_handle_v1 is
+// created and each time the workspace state changes, either because of a
+// compositor action or because of a request in this protocol.
+//
+// Missing states convey the opposite meaning, e.g. an unset active bit
+// means the workspace is currently inactive.
+type ExtWorkspaceHandleV1StateEvent struct {
+ State uint32
+}
+type ExtWorkspaceHandleV1StateHandlerFunc func(ExtWorkspaceHandleV1StateEvent)
+
+// SetStateHandler : sets handler for ExtWorkspaceHandleV1StateEvent
+func (i *ExtWorkspaceHandleV1) SetStateHandler(f ExtWorkspaceHandleV1StateHandlerFunc) {
+ i.stateHandler = f
+}
+
+// ExtWorkspaceHandleV1CapabilitiesEvent : compositor capabilities
+//
+// This event advertises the capabilities supported by the compositor. If
+// a capability isn't supported, clients should hide or disable the UI
+// elements that expose this functionality. For instance, if the
+// compositor doesn't advertise support for removing workspaces, a button
+// triggering the remove request should not be displayed.
+//
+// The compositor will ignore requests it doesn't support. For instance,
+// a compositor which doesn't advertise support for remove will ignore
+// remove requests.
+//
+// Compositors must send this event once after creation of an
+// ext_workspace_handle_v1 . When the capabilities change, compositors
+// must send this event again.
+type ExtWorkspaceHandleV1CapabilitiesEvent struct {
+ Capabilities uint32
+}
+type ExtWorkspaceHandleV1CapabilitiesHandlerFunc func(ExtWorkspaceHandleV1CapabilitiesEvent)
+
+// SetCapabilitiesHandler : sets handler for ExtWorkspaceHandleV1CapabilitiesEvent
+func (i *ExtWorkspaceHandleV1) SetCapabilitiesHandler(f ExtWorkspaceHandleV1CapabilitiesHandlerFunc) {
+ i.capabilitiesHandler = f
+}
+
+// ExtWorkspaceHandleV1RemovedEvent : this workspace has been removed
+//
+// This event is send when the workspace associated with the ext_workspace_handle_v1
+// has been removed. After sending this request, the compositor will immediately consider
+// the object inert. Any requests will be ignored except the destroy request.
+//
+// It is guaranteed there won't be any more events referencing this
+// ext_workspace_handle_v1.
+//
+// The compositor must only remove a workspaces not currently belonging to any
+// workspace_group.
+type ExtWorkspaceHandleV1RemovedEvent struct{}
+type ExtWorkspaceHandleV1RemovedHandlerFunc func(ExtWorkspaceHandleV1RemovedEvent)
+
+// SetRemovedHandler : sets handler for ExtWorkspaceHandleV1RemovedEvent
+func (i *ExtWorkspaceHandleV1) SetRemovedHandler(f ExtWorkspaceHandleV1RemovedHandlerFunc) {
+ i.removedHandler = f
+}
+
+func (i *ExtWorkspaceHandleV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.idHandler == nil {
+ return
+ }
+ var e ExtWorkspaceHandleV1IdEvent
+ l := 0
+ idLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Id = client.String(data[l : l+idLen])
+ l += idLen
+
+ i.idHandler(e)
+ case 1:
+ if i.nameHandler == nil {
+ return
+ }
+ var e ExtWorkspaceHandleV1NameEvent
+ l := 0
+ nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Name = client.String(data[l : l+nameLen])
+ l += nameLen
+
+ i.nameHandler(e)
+ case 2:
+ if i.coordinatesHandler == nil {
+ return
+ }
+ var e ExtWorkspaceHandleV1CoordinatesEvent
+ l := 0
+ coordinatesLen := int(client.Uint32(data[l : l+4]))
+ l += 4
+ e.Coordinates = make([]byte, coordinatesLen)
+ copy(e.Coordinates, data[l:l+coordinatesLen])
+ l += coordinatesLen
+
+ i.coordinatesHandler(e)
+ case 3:
+ if i.stateHandler == nil {
+ return
+ }
+ var e ExtWorkspaceHandleV1StateEvent
+ l := 0
+ e.State = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.stateHandler(e)
+ case 4:
+ if i.capabilitiesHandler == nil {
+ return
+ }
+ var e ExtWorkspaceHandleV1CapabilitiesEvent
+ l := 0
+ e.Capabilities = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.capabilitiesHandler(e)
+ case 5:
+ if i.removedHandler == nil {
+ return
+ }
+ var e ExtWorkspaceHandleV1RemovedEvent
+
+ i.removedHandler(e)
+ }
+}
diff --git a/backend/internal/proto/wlr_gamma_control/gamma_control.go b/backend/internal/proto/wlr_gamma_control/gamma_control.go
new file mode 100644
index 00000000..63e1b6a5
--- /dev/null
+++ b/backend/internal/proto/wlr_gamma_control/gamma_control.go
@@ -0,0 +1,268 @@
+// Generated by go-wayland-scanner
+// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
+// XML file : wayland-protocols/wlr-gamma-control-unstable-v1.xml
+//
+// wlr_gamma_control_unstable_v1 Protocol Copyright:
+//
+// Copyright © 2015 Giulio camuffo
+// Copyright © 2018 Simon Ser
+//
+// Permission to use, copy, modify, distribute, and sell this
+// software and its documentation for any purpose is hereby granted
+// without fee, provided that the above copyright notice appear in
+// all copies and that both that copyright notice and this permission
+// notice appear in supporting documentation, and that the name of
+// the copyright holders not be used in advertising or publicity
+// pertaining to distribution of the software without specific,
+// written prior permission. The copyright holders make no
+// representations about the suitability of this software for any
+// purpose. It is provided "as is" without express or implied
+// warranty.
+//
+// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+// THIS SOFTWARE.
+
+package wlr_gamma_control
+
+import (
+ "github.com/yaslama/go-wayland/wayland/client"
+ "golang.org/x/sys/unix"
+)
+
+// ZwlrGammaControlManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZwlrGammaControlManagerV1InterfaceName = "zwlr_gamma_control_manager_v1"
+
+// ZwlrGammaControlManagerV1 : manager to create per-output gamma controls
+//
+// This interface is a manager that allows creating per-output gamma
+// controls.
+type ZwlrGammaControlManagerV1 struct {
+ client.BaseProxy
+}
+
+// NewZwlrGammaControlManagerV1 : manager to create per-output gamma controls
+//
+// This interface is a manager that allows creating per-output gamma
+// controls.
+func NewZwlrGammaControlManagerV1(ctx *client.Context) *ZwlrGammaControlManagerV1 {
+ zwlrGammaControlManagerV1 := &ZwlrGammaControlManagerV1{}
+ ctx.Register(zwlrGammaControlManagerV1)
+ return zwlrGammaControlManagerV1
+}
+
+// GetGammaControl : get a gamma control for an output
+//
+// Create a gamma control that can be used to adjust gamma tables for the
+// provided output.
+func (i *ZwlrGammaControlManagerV1) GetGammaControl(output *client.Output) (*ZwlrGammaControlV1, error) {
+ id := NewZwlrGammaControlV1(i.Context())
+ const opcode = 0
+ const _reqBufLen = 8 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], id.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], output.ID())
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return id, err
+}
+
+// Destroy : destroy the manager
+//
+// All objects created by the manager will still remain valid, until their
+// appropriate destroy request has been called.
+func (i *ZwlrGammaControlManagerV1) Destroy() error {
+ defer i.Context().Unregister(i)
+ const opcode = 1
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// ZwlrGammaControlV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZwlrGammaControlV1InterfaceName = "zwlr_gamma_control_v1"
+
+// ZwlrGammaControlV1 : adjust gamma tables for an output
+//
+// This interface allows a client to adjust gamma tables for a particular
+// output.
+//
+// The client will receive the gamma size, and will then be able to set gamma
+// tables. At any time the compositor can send a failed event indicating that
+// this object is no longer valid.
+//
+// There can only be at most one gamma control object per output, which
+// has exclusive access to this particular output. When the gamma control
+// object is destroyed, the gamma table is restored to its original value.
+type ZwlrGammaControlV1 struct {
+ client.BaseProxy
+ gammaSizeHandler ZwlrGammaControlV1GammaSizeHandlerFunc
+ failedHandler ZwlrGammaControlV1FailedHandlerFunc
+}
+
+// NewZwlrGammaControlV1 : adjust gamma tables for an output
+//
+// This interface allows a client to adjust gamma tables for a particular
+// output.
+//
+// The client will receive the gamma size, and will then be able to set gamma
+// tables. At any time the compositor can send a failed event indicating that
+// this object is no longer valid.
+//
+// There can only be at most one gamma control object per output, which
+// has exclusive access to this particular output. When the gamma control
+// object is destroyed, the gamma table is restored to its original value.
+func NewZwlrGammaControlV1(ctx *client.Context) *ZwlrGammaControlV1 {
+ zwlrGammaControlV1 := &ZwlrGammaControlV1{}
+ ctx.Register(zwlrGammaControlV1)
+ return zwlrGammaControlV1
+}
+
+// SetGamma : set the gamma table
+//
+// Set the gamma table. The file descriptor can be memory-mapped to provide
+// the raw gamma table, which contains successive gamma ramps for the red,
+// green and blue channels. Each gamma ramp is an array of 16-byte unsigned
+// integers which has the same length as the gamma size.
+//
+// The file descriptor data must have the same length as three times the
+// gamma size.
+//
+// fd: gamma table file descriptor
+func (i *ZwlrGammaControlV1) SetGamma(fd int) error {
+ const opcode = 0
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ oob := unix.UnixRights(int(fd))
+ err := i.Context().WriteMsg(_reqBuf[:], oob)
+ return err
+}
+
+// Destroy : destroy this control
+//
+// Destroys the gamma control object. If the object is still valid, this
+// restores the original gamma tables.
+func (i *ZwlrGammaControlV1) Destroy() error {
+ defer i.Context().Unregister(i)
+ const opcode = 1
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+type ZwlrGammaControlV1Error uint32
+
+// ZwlrGammaControlV1Error :
+const (
+ // ZwlrGammaControlV1ErrorInvalidGamma : invalid gamma tables
+ ZwlrGammaControlV1ErrorInvalidGamma ZwlrGammaControlV1Error = 1
+)
+
+func (e ZwlrGammaControlV1Error) Name() string {
+ switch e {
+ case ZwlrGammaControlV1ErrorInvalidGamma:
+ return "invalid_gamma"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrGammaControlV1Error) Value() string {
+ switch e {
+ case ZwlrGammaControlV1ErrorInvalidGamma:
+ return "1"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrGammaControlV1Error) String() string {
+ return e.Name() + "=" + e.Value()
+}
+
+// ZwlrGammaControlV1GammaSizeEvent : size of gamma ramps
+//
+// Advertise the size of each gamma ramp.
+//
+// This event is sent immediately when the gamma control object is created.
+type ZwlrGammaControlV1GammaSizeEvent struct {
+ Size uint32
+}
+type ZwlrGammaControlV1GammaSizeHandlerFunc func(ZwlrGammaControlV1GammaSizeEvent)
+
+// SetGammaSizeHandler : sets handler for ZwlrGammaControlV1GammaSizeEvent
+func (i *ZwlrGammaControlV1) SetGammaSizeHandler(f ZwlrGammaControlV1GammaSizeHandlerFunc) {
+ i.gammaSizeHandler = f
+}
+
+// ZwlrGammaControlV1FailedEvent : object no longer valid
+//
+// This event indicates that the gamma control is no longer valid. This
+// can happen for a number of reasons, including:
+// - The output doesn't support gamma tables
+// - Setting the gamma tables failed
+// - Another client already has exclusive gamma control for this output
+// - The compositor has transferred gamma control to another client
+//
+// Upon receiving this event, the client should destroy this object.
+type ZwlrGammaControlV1FailedEvent struct{}
+type ZwlrGammaControlV1FailedHandlerFunc func(ZwlrGammaControlV1FailedEvent)
+
+// SetFailedHandler : sets handler for ZwlrGammaControlV1FailedEvent
+func (i *ZwlrGammaControlV1) SetFailedHandler(f ZwlrGammaControlV1FailedHandlerFunc) {
+ i.failedHandler = f
+}
+
+func (i *ZwlrGammaControlV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.gammaSizeHandler == nil {
+ return
+ }
+ var e ZwlrGammaControlV1GammaSizeEvent
+ l := 0
+ e.Size = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.gammaSizeHandler(e)
+ case 1:
+ if i.failedHandler == nil {
+ return
+ }
+ var e ZwlrGammaControlV1FailedEvent
+
+ i.failedHandler(e)
+ }
+}
diff --git a/backend/internal/proto/wlr_output_management/output_management.go b/backend/internal/proto/wlr_output_management/output_management.go
new file mode 100644
index 00000000..eeaf7012
--- /dev/null
+++ b/backend/internal/proto/wlr_output_management/output_management.go
@@ -0,0 +1,1479 @@
+// Generated by go-wayland-scanner
+// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
+// XML file : /home/brandon/repos/dankdots/wlr-output-management-unstable-v1.xml
+//
+// wlr_output_management_unstable_v1 Protocol Copyright:
+//
+// Copyright © 2019 Purism SPC
+//
+// Permission to use, copy, modify, distribute, and sell this
+// software and its documentation for any purpose is hereby granted
+// without fee, provided that the above copyright notice appear in
+// all copies and that both that copyright notice and this permission
+// notice appear in supporting documentation, and that the name of
+// the copyright holders not be used in advertising or publicity
+// pertaining to distribution of the software without specific,
+// written prior permission. The copyright holders make no
+// representations about the suitability of this software for any
+// purpose. It is provided "as is" without express or implied
+// warranty.
+//
+// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+// THIS SOFTWARE.
+
+package wlr_output_management
+
+import (
+ "reflect"
+ "unsafe"
+
+ "github.com/yaslama/go-wayland/wayland/client"
+)
+
+func registerServerProxy(ctx *client.Context, proxy client.Proxy, serverID uint32) {
+ defer func() {
+ if r := recover(); r != nil {
+ return
+ }
+ }()
+ ctxVal := reflect.ValueOf(ctx).Elem()
+ objectsField := ctxVal.FieldByName("objects")
+ if !objectsField.IsValid() {
+ return
+ }
+ objectsField = reflect.NewAt(objectsField.Type(), unsafe.Pointer(objectsField.UnsafeAddr())).Elem()
+ objectsMap := objectsField.Interface().(map[uint32]client.Proxy)
+ objectsMap[serverID] = proxy
+}
+
+// ZwlrOutputManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZwlrOutputManagerV1InterfaceName = "zwlr_output_manager_v1"
+
+// ZwlrOutputManagerV1 : output device configuration manager
+//
+// This interface is a manager that allows reading and writing the current
+// output device configuration.
+//
+// Output devices that display pixels (e.g. a physical monitor or a virtual
+// output in a window) are represented as heads. Heads cannot be created nor
+// destroyed by the client, but they can be enabled or disabled and their
+// properties can be changed. Each head may have one or more available modes.
+//
+// Whenever a head appears (e.g. a monitor is plugged in), it will be
+// advertised via the head event. Immediately after the output manager is
+// bound, all current heads are advertised.
+//
+// Whenever a head's properties change, the relevant wlr_output_head events
+// will be sent. Not all head properties will be sent: only properties that
+// have changed need to.
+//
+// Whenever a head disappears (e.g. a monitor is unplugged), a
+// wlr_output_head.finished event will be sent.
+//
+// After one or more heads appear, change or disappear, the done event will
+// be sent. It carries a serial which can be used in a create_configuration
+// request to update heads properties.
+//
+// The information obtained from this protocol should only be used for output
+// configuration purposes. This protocol is not designed to be a generic
+// output property advertisement protocol for regular clients. Instead,
+// protocols such as xdg-output should be used.
+type ZwlrOutputManagerV1 struct {
+ client.BaseProxy
+ headHandler ZwlrOutputManagerV1HeadHandlerFunc
+ doneHandler ZwlrOutputManagerV1DoneHandlerFunc
+ finishedHandler ZwlrOutputManagerV1FinishedHandlerFunc
+}
+
+// NewZwlrOutputManagerV1 : output device configuration manager
+//
+// This interface is a manager that allows reading and writing the current
+// output device configuration.
+//
+// Output devices that display pixels (e.g. a physical monitor or a virtual
+// output in a window) are represented as heads. Heads cannot be created nor
+// destroyed by the client, but they can be enabled or disabled and their
+// properties can be changed. Each head may have one or more available modes.
+//
+// Whenever a head appears (e.g. a monitor is plugged in), it will be
+// advertised via the head event. Immediately after the output manager is
+// bound, all current heads are advertised.
+//
+// Whenever a head's properties change, the relevant wlr_output_head events
+// will be sent. Not all head properties will be sent: only properties that
+// have changed need to.
+//
+// Whenever a head disappears (e.g. a monitor is unplugged), a
+// wlr_output_head.finished event will be sent.
+//
+// After one or more heads appear, change or disappear, the done event will
+// be sent. It carries a serial which can be used in a create_configuration
+// request to update heads properties.
+//
+// The information obtained from this protocol should only be used for output
+// configuration purposes. This protocol is not designed to be a generic
+// output property advertisement protocol for regular clients. Instead,
+// protocols such as xdg-output should be used.
+func NewZwlrOutputManagerV1(ctx *client.Context) *ZwlrOutputManagerV1 {
+ zwlrOutputManagerV1 := &ZwlrOutputManagerV1{}
+ ctx.Register(zwlrOutputManagerV1)
+ return zwlrOutputManagerV1
+}
+
+// CreateConfiguration : create a new output configuration object
+//
+// Create a new output configuration object. This allows to update head
+// properties.
+func (i *ZwlrOutputManagerV1) CreateConfiguration(serial uint32) (*ZwlrOutputConfigurationV1, error) {
+ id := NewZwlrOutputConfigurationV1(i.Context())
+ const opcode = 0
+ const _reqBufLen = 8 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], id.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(serial))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return id, err
+}
+
+// Stop : stop sending events
+//
+// Indicates the client no longer wishes to receive events for output
+// configuration changes. However the compositor may emit further events,
+// until the finished event is emitted.
+//
+// The client must not send any more requests after this one.
+func (i *ZwlrOutputManagerV1) Stop() error {
+ const opcode = 1
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+func (i *ZwlrOutputManagerV1) Destroy() error {
+ i.Context().Unregister(i)
+ return nil
+}
+
+// ZwlrOutputManagerV1HeadEvent : introduce a new head
+//
+// This event introduces a new head. This happens whenever a new head
+// appears (e.g. a monitor is plugged in) or after the output manager is
+// bound.
+type ZwlrOutputManagerV1HeadEvent struct {
+ Head *ZwlrOutputHeadV1
+}
+type ZwlrOutputManagerV1HeadHandlerFunc func(ZwlrOutputManagerV1HeadEvent)
+
+// SetHeadHandler : sets handler for ZwlrOutputManagerV1HeadEvent
+func (i *ZwlrOutputManagerV1) SetHeadHandler(f ZwlrOutputManagerV1HeadHandlerFunc) {
+ i.headHandler = f
+}
+
+// ZwlrOutputManagerV1DoneEvent : sent all information about current configuration
+//
+// This event is sent after all information has been sent after binding to
+// the output manager object and after any subsequent changes. This applies
+// to child head and mode objects as well. In other words, this event is
+// sent whenever a head or mode is created or destroyed and whenever one of
+// their properties has been changed. Not all state is re-sent each time
+// the current configuration changes: only the actual changes are sent.
+//
+// This allows changes to the output configuration to be seen as atomic,
+// even if they happen via multiple events.
+//
+// A serial is sent to be used in a future create_configuration request.
+type ZwlrOutputManagerV1DoneEvent struct {
+ Serial uint32
+}
+type ZwlrOutputManagerV1DoneHandlerFunc func(ZwlrOutputManagerV1DoneEvent)
+
+// SetDoneHandler : sets handler for ZwlrOutputManagerV1DoneEvent
+func (i *ZwlrOutputManagerV1) SetDoneHandler(f ZwlrOutputManagerV1DoneHandlerFunc) {
+ i.doneHandler = f
+}
+
+// ZwlrOutputManagerV1FinishedEvent : the compositor has finished with the manager
+//
+// This event indicates that the compositor is done sending manager events.
+// The compositor will destroy the object immediately after sending this
+// event, so it will become invalid and the client should release any
+// resources associated with it.
+type ZwlrOutputManagerV1FinishedEvent struct{}
+type ZwlrOutputManagerV1FinishedHandlerFunc func(ZwlrOutputManagerV1FinishedEvent)
+
+// SetFinishedHandler : sets handler for ZwlrOutputManagerV1FinishedEvent
+func (i *ZwlrOutputManagerV1) SetFinishedHandler(f ZwlrOutputManagerV1FinishedHandlerFunc) {
+ i.finishedHandler = f
+}
+
+func (i *ZwlrOutputManagerV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.headHandler == nil {
+ return
+ }
+ var e ZwlrOutputManagerV1HeadEvent
+ l := 0
+ objectID := client.Uint32(data[l : l+4])
+ proxy := i.Context().GetProxy(objectID)
+ if proxy != nil {
+ e.Head = proxy.(*ZwlrOutputHeadV1)
+ } else {
+ head := &ZwlrOutputHeadV1{}
+ head.SetContext(i.Context())
+ head.SetID(objectID)
+ registerServerProxy(i.Context(), head, objectID)
+ e.Head = head
+ }
+ l += 4
+
+ i.headHandler(e)
+ case 1:
+ if i.doneHandler == nil {
+ return
+ }
+ var e ZwlrOutputManagerV1DoneEvent
+ l := 0
+ e.Serial = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.doneHandler(e)
+ case 2:
+ if i.finishedHandler == nil {
+ return
+ }
+ var e ZwlrOutputManagerV1FinishedEvent
+
+ i.finishedHandler(e)
+ }
+}
+
+// ZwlrOutputHeadV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZwlrOutputHeadV1InterfaceName = "zwlr_output_head_v1"
+
+// ZwlrOutputHeadV1 : output device
+//
+// A head is an output device. The difference between a wl_output object and
+// a head is that heads are advertised even if they are turned off. A head
+// object only advertises properties and cannot be used directly to change
+// them.
+//
+// A head has some read-only properties: modes, name, description and
+// physical_size. These cannot be changed by clients.
+//
+// Other properties can be updated via a wlr_output_configuration object.
+//
+// Properties sent via this interface are applied atomically via the
+// wlr_output_manager.done event. No guarantees are made regarding the order
+// in which properties are sent.
+type ZwlrOutputHeadV1 struct {
+ client.BaseProxy
+ nameHandler ZwlrOutputHeadV1NameHandlerFunc
+ descriptionHandler ZwlrOutputHeadV1DescriptionHandlerFunc
+ physicalSizeHandler ZwlrOutputHeadV1PhysicalSizeHandlerFunc
+ modeHandler ZwlrOutputHeadV1ModeHandlerFunc
+ enabledHandler ZwlrOutputHeadV1EnabledHandlerFunc
+ currentModeHandler ZwlrOutputHeadV1CurrentModeHandlerFunc
+ positionHandler ZwlrOutputHeadV1PositionHandlerFunc
+ transformHandler ZwlrOutputHeadV1TransformHandlerFunc
+ scaleHandler ZwlrOutputHeadV1ScaleHandlerFunc
+ finishedHandler ZwlrOutputHeadV1FinishedHandlerFunc
+ makeHandler ZwlrOutputHeadV1MakeHandlerFunc
+ modelHandler ZwlrOutputHeadV1ModelHandlerFunc
+ serialNumberHandler ZwlrOutputHeadV1SerialNumberHandlerFunc
+ adaptiveSyncHandler ZwlrOutputHeadV1AdaptiveSyncHandlerFunc
+}
+
+// NewZwlrOutputHeadV1 : output device
+//
+// A head is an output device. The difference between a wl_output object and
+// a head is that heads are advertised even if they are turned off. A head
+// object only advertises properties and cannot be used directly to change
+// them.
+//
+// A head has some read-only properties: modes, name, description and
+// physical_size. These cannot be changed by clients.
+//
+// Other properties can be updated via a wlr_output_configuration object.
+//
+// Properties sent via this interface are applied atomically via the
+// wlr_output_manager.done event. No guarantees are made regarding the order
+// in which properties are sent.
+func NewZwlrOutputHeadV1(ctx *client.Context) *ZwlrOutputHeadV1 {
+ zwlrOutputHeadV1 := &ZwlrOutputHeadV1{}
+ ctx.Register(zwlrOutputHeadV1)
+ return zwlrOutputHeadV1
+}
+
+// Release : destroy the head object
+//
+// This request indicates that the client will no longer use this head
+// object.
+func (i *ZwlrOutputHeadV1) Release() error {
+ defer i.Context().Unregister(i)
+ const opcode = 0
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+type ZwlrOutputHeadV1AdaptiveSyncState uint32
+
+// ZwlrOutputHeadV1AdaptiveSyncState :
+const (
+ // ZwlrOutputHeadV1AdaptiveSyncStateDisabled : adaptive sync is disabled
+ ZwlrOutputHeadV1AdaptiveSyncStateDisabled ZwlrOutputHeadV1AdaptiveSyncState = 0
+ // ZwlrOutputHeadV1AdaptiveSyncStateEnabled : adaptive sync is enabled
+ ZwlrOutputHeadV1AdaptiveSyncStateEnabled ZwlrOutputHeadV1AdaptiveSyncState = 1
+)
+
+func (e ZwlrOutputHeadV1AdaptiveSyncState) Name() string {
+ switch e {
+ case ZwlrOutputHeadV1AdaptiveSyncStateDisabled:
+ return "disabled"
+ case ZwlrOutputHeadV1AdaptiveSyncStateEnabled:
+ return "enabled"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrOutputHeadV1AdaptiveSyncState) Value() string {
+ switch e {
+ case ZwlrOutputHeadV1AdaptiveSyncStateDisabled:
+ return "0"
+ case ZwlrOutputHeadV1AdaptiveSyncStateEnabled:
+ return "1"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrOutputHeadV1AdaptiveSyncState) String() string {
+ return e.Name() + "=" + e.Value()
+}
+
+// ZwlrOutputHeadV1NameEvent : head name
+//
+// This event describes the head name.
+//
+// The naming convention is compositor defined, but limited to alphanumeric
+// characters and dashes (-). Each name is unique among all wlr_output_head
+// objects, but if a wlr_output_head object is destroyed the same name may
+// be reused later. The names will also remain consistent across sessions
+// with the same hardware and software configuration.
+//
+// Examples of names include 'HDMI-A-1', 'WL-1', 'X11-1', etc. However, do
+// not assume that the name is a reflection of an underlying DRM
+// connector, X11 connection, etc.
+//
+// If this head matches a wl_output, the wl_output.name event must report
+// the same name.
+//
+// The name event is sent after a wlr_output_head object is created. This
+// event is only sent once per object, and the name does not change over
+// the lifetime of the wlr_output_head object.
+type ZwlrOutputHeadV1NameEvent struct {
+ Name string
+}
+type ZwlrOutputHeadV1NameHandlerFunc func(ZwlrOutputHeadV1NameEvent)
+
+// SetNameHandler : sets handler for ZwlrOutputHeadV1NameEvent
+func (i *ZwlrOutputHeadV1) SetNameHandler(f ZwlrOutputHeadV1NameHandlerFunc) {
+ i.nameHandler = f
+}
+
+// ZwlrOutputHeadV1DescriptionEvent : head description
+//
+// This event describes a human-readable description of the head.
+//
+// The description is a UTF-8 string with no convention defined for its
+// contents. Examples might include 'Foocorp 11" Display' or 'Virtual X11
+// output via :1'. However, do not assume that the name is a reflection of
+// the make, model, serial of the underlying DRM connector or the display
+// name of the underlying X11 connection, etc.
+//
+// If this head matches a wl_output, the wl_output.description event must
+// report the same name.
+//
+// The description event is sent after a wlr_output_head object is created.
+// This event is only sent once per object, and the description does not
+// change over the lifetime of the wlr_output_head object.
+type ZwlrOutputHeadV1DescriptionEvent struct {
+ Description string
+}
+type ZwlrOutputHeadV1DescriptionHandlerFunc func(ZwlrOutputHeadV1DescriptionEvent)
+
+// SetDescriptionHandler : sets handler for ZwlrOutputHeadV1DescriptionEvent
+func (i *ZwlrOutputHeadV1) SetDescriptionHandler(f ZwlrOutputHeadV1DescriptionHandlerFunc) {
+ i.descriptionHandler = f
+}
+
+// ZwlrOutputHeadV1PhysicalSizeEvent : head physical size
+//
+// This event describes the physical size of the head. This event is only
+// sent if the head has a physical size (e.g. is not a projector or a
+// virtual device).
+//
+// The physical size event is sent after a wlr_output_head object is created. This
+// event is only sent once per object, and the physical size does not change over
+// the lifetime of the wlr_output_head object.
+type ZwlrOutputHeadV1PhysicalSizeEvent struct {
+ Width int32
+ Height int32
+}
+type ZwlrOutputHeadV1PhysicalSizeHandlerFunc func(ZwlrOutputHeadV1PhysicalSizeEvent)
+
+// SetPhysicalSizeHandler : sets handler for ZwlrOutputHeadV1PhysicalSizeEvent
+func (i *ZwlrOutputHeadV1) SetPhysicalSizeHandler(f ZwlrOutputHeadV1PhysicalSizeHandlerFunc) {
+ i.physicalSizeHandler = f
+}
+
+// ZwlrOutputHeadV1ModeEvent : introduce a mode
+//
+// This event introduces a mode for this head. It is sent once per
+// supported mode.
+type ZwlrOutputHeadV1ModeEvent struct {
+ Mode *ZwlrOutputModeV1
+}
+type ZwlrOutputHeadV1ModeHandlerFunc func(ZwlrOutputHeadV1ModeEvent)
+
+// SetModeHandler : sets handler for ZwlrOutputHeadV1ModeEvent
+func (i *ZwlrOutputHeadV1) SetModeHandler(f ZwlrOutputHeadV1ModeHandlerFunc) {
+ i.modeHandler = f
+}
+
+// ZwlrOutputHeadV1EnabledEvent : head is enabled or disabled
+//
+// This event describes whether the head is enabled. A disabled head is not
+// mapped to a region of the global compositor space.
+//
+// When a head is disabled, some properties (current_mode, position,
+// transform and scale) are irrelevant.
+type ZwlrOutputHeadV1EnabledEvent struct {
+ Enabled int32
+}
+type ZwlrOutputHeadV1EnabledHandlerFunc func(ZwlrOutputHeadV1EnabledEvent)
+
+// SetEnabledHandler : sets handler for ZwlrOutputHeadV1EnabledEvent
+func (i *ZwlrOutputHeadV1) SetEnabledHandler(f ZwlrOutputHeadV1EnabledHandlerFunc) {
+ i.enabledHandler = f
+}
+
+// ZwlrOutputHeadV1CurrentModeEvent : current mode
+//
+// This event describes the mode currently in use for this head. It is only
+// sent if the output is enabled.
+type ZwlrOutputHeadV1CurrentModeEvent struct {
+ Mode *ZwlrOutputModeV1
+}
+type ZwlrOutputHeadV1CurrentModeHandlerFunc func(ZwlrOutputHeadV1CurrentModeEvent)
+
+// SetCurrentModeHandler : sets handler for ZwlrOutputHeadV1CurrentModeEvent
+func (i *ZwlrOutputHeadV1) SetCurrentModeHandler(f ZwlrOutputHeadV1CurrentModeHandlerFunc) {
+ i.currentModeHandler = f
+}
+
+// ZwlrOutputHeadV1PositionEvent : current position
+//
+// This events describes the position of the head in the global compositor
+// space. It is only sent if the output is enabled.
+type ZwlrOutputHeadV1PositionEvent struct {
+ X int32
+ Y int32
+}
+type ZwlrOutputHeadV1PositionHandlerFunc func(ZwlrOutputHeadV1PositionEvent)
+
+// SetPositionHandler : sets handler for ZwlrOutputHeadV1PositionEvent
+func (i *ZwlrOutputHeadV1) SetPositionHandler(f ZwlrOutputHeadV1PositionHandlerFunc) {
+ i.positionHandler = f
+}
+
+// ZwlrOutputHeadV1TransformEvent : current transformation
+//
+// This event describes the transformation currently applied to the head.
+// It is only sent if the output is enabled.
+type ZwlrOutputHeadV1TransformEvent struct {
+ Transform int32
+}
+type ZwlrOutputHeadV1TransformHandlerFunc func(ZwlrOutputHeadV1TransformEvent)
+
+// SetTransformHandler : sets handler for ZwlrOutputHeadV1TransformEvent
+func (i *ZwlrOutputHeadV1) SetTransformHandler(f ZwlrOutputHeadV1TransformHandlerFunc) {
+ i.transformHandler = f
+}
+
+// ZwlrOutputHeadV1ScaleEvent : current scale
+//
+// This events describes the scale of the head in the global compositor
+// space. It is only sent if the output is enabled.
+type ZwlrOutputHeadV1ScaleEvent struct {
+ Scale float64
+}
+type ZwlrOutputHeadV1ScaleHandlerFunc func(ZwlrOutputHeadV1ScaleEvent)
+
+// SetScaleHandler : sets handler for ZwlrOutputHeadV1ScaleEvent
+func (i *ZwlrOutputHeadV1) SetScaleHandler(f ZwlrOutputHeadV1ScaleHandlerFunc) {
+ i.scaleHandler = f
+}
+
+// ZwlrOutputHeadV1FinishedEvent : the head has disappeared
+//
+// This event indicates that the head is no longer available. The head
+// object becomes inert. Clients should send a destroy request and release
+// any resources associated with it.
+type ZwlrOutputHeadV1FinishedEvent struct{}
+type ZwlrOutputHeadV1FinishedHandlerFunc func(ZwlrOutputHeadV1FinishedEvent)
+
+// SetFinishedHandler : sets handler for ZwlrOutputHeadV1FinishedEvent
+func (i *ZwlrOutputHeadV1) SetFinishedHandler(f ZwlrOutputHeadV1FinishedHandlerFunc) {
+ i.finishedHandler = f
+}
+
+// ZwlrOutputHeadV1MakeEvent : head manufacturer
+//
+// This event describes the manufacturer of the head.
+//
+// Together with the model and serial_number events the purpose is to
+// allow clients to recognize heads from previous sessions and for example
+// load head-specific configurations back.
+//
+// It is not guaranteed this event will be ever sent. A reason for that
+// can be that the compositor does not have information about the make of
+// the head or the definition of a make is not sensible in the current
+// setup, for example in a virtual session. Clients can still try to
+// identify the head by available information from other events but should
+// be aware that there is an increased risk of false positives.
+//
+// If sent, the make event is sent after a wlr_output_head object is
+// created and only sent once per object. The make does not change over
+// the lifetime of the wlr_output_head object.
+//
+// It is not recommended to display the make string in UI to users. For
+// that the string provided by the description event should be preferred.
+type ZwlrOutputHeadV1MakeEvent struct {
+ Make string
+}
+type ZwlrOutputHeadV1MakeHandlerFunc func(ZwlrOutputHeadV1MakeEvent)
+
+// SetMakeHandler : sets handler for ZwlrOutputHeadV1MakeEvent
+func (i *ZwlrOutputHeadV1) SetMakeHandler(f ZwlrOutputHeadV1MakeHandlerFunc) {
+ i.makeHandler = f
+}
+
+// ZwlrOutputHeadV1ModelEvent : head model
+//
+// This event describes the model of the head.
+//
+// Together with the make and serial_number events the purpose is to
+// allow clients to recognize heads from previous sessions and for example
+// load head-specific configurations back.
+//
+// It is not guaranteed this event will be ever sent. A reason for that
+// can be that the compositor does not have information about the model of
+// the head or the definition of a model is not sensible in the current
+// setup, for example in a virtual session. Clients can still try to
+// identify the head by available information from other events but should
+// be aware that there is an increased risk of false positives.
+//
+// If sent, the model event is sent after a wlr_output_head object is
+// created and only sent once per object. The model does not change over
+// the lifetime of the wlr_output_head object.
+//
+// It is not recommended to display the model string in UI to users. For
+// that the string provided by the description event should be preferred.
+type ZwlrOutputHeadV1ModelEvent struct {
+ Model string
+}
+type ZwlrOutputHeadV1ModelHandlerFunc func(ZwlrOutputHeadV1ModelEvent)
+
+// SetModelHandler : sets handler for ZwlrOutputHeadV1ModelEvent
+func (i *ZwlrOutputHeadV1) SetModelHandler(f ZwlrOutputHeadV1ModelHandlerFunc) {
+ i.modelHandler = f
+}
+
+// ZwlrOutputHeadV1SerialNumberEvent : head serial number
+//
+// This event describes the serial number of the head.
+//
+// Together with the make and model events the purpose is to allow clients
+// to recognize heads from previous sessions and for example load head-
+// specific configurations back.
+//
+// It is not guaranteed this event will be ever sent. A reason for that
+// can be that the compositor does not have information about the serial
+// number of the head or the definition of a serial number is not sensible
+// in the current setup. Clients can still try to identify the head by
+// available information from other events but should be aware that there
+// is an increased risk of false positives.
+//
+// If sent, the serial number event is sent after a wlr_output_head object
+// is created and only sent once per object. The serial number does not
+// change over the lifetime of the wlr_output_head object.
+//
+// It is not recommended to display the serial_number string in UI to
+// users. For that the string provided by the description event should be
+// preferred.
+type ZwlrOutputHeadV1SerialNumberEvent struct {
+ SerialNumber string
+}
+type ZwlrOutputHeadV1SerialNumberHandlerFunc func(ZwlrOutputHeadV1SerialNumberEvent)
+
+// SetSerialNumberHandler : sets handler for ZwlrOutputHeadV1SerialNumberEvent
+func (i *ZwlrOutputHeadV1) SetSerialNumberHandler(f ZwlrOutputHeadV1SerialNumberHandlerFunc) {
+ i.serialNumberHandler = f
+}
+
+// ZwlrOutputHeadV1AdaptiveSyncEvent : current adaptive sync state
+//
+// This event describes whether adaptive sync is currently enabled for
+// the head or not. Adaptive sync is also known as Variable Refresh
+// Rate or VRR.
+type ZwlrOutputHeadV1AdaptiveSyncEvent struct {
+ State uint32
+}
+type ZwlrOutputHeadV1AdaptiveSyncHandlerFunc func(ZwlrOutputHeadV1AdaptiveSyncEvent)
+
+// SetAdaptiveSyncHandler : sets handler for ZwlrOutputHeadV1AdaptiveSyncEvent
+func (i *ZwlrOutputHeadV1) SetAdaptiveSyncHandler(f ZwlrOutputHeadV1AdaptiveSyncHandlerFunc) {
+ i.adaptiveSyncHandler = f
+}
+
+func (i *ZwlrOutputHeadV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.nameHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1NameEvent
+ l := 0
+ nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Name = client.String(data[l : l+nameLen])
+ l += nameLen
+
+ i.nameHandler(e)
+ case 1:
+ if i.descriptionHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1DescriptionEvent
+ l := 0
+ descriptionLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Description = client.String(data[l : l+descriptionLen])
+ l += descriptionLen
+
+ i.descriptionHandler(e)
+ case 2:
+ if i.physicalSizeHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1PhysicalSizeEvent
+ l := 0
+ e.Width = int32(client.Uint32(data[l : l+4]))
+ l += 4
+ e.Height = int32(client.Uint32(data[l : l+4]))
+ l += 4
+
+ i.physicalSizeHandler(e)
+ case 3:
+ if i.modeHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1ModeEvent
+ l := 0
+ objectID := client.Uint32(data[l : l+4])
+ proxy := i.Context().GetProxy(objectID)
+ if proxy != nil {
+ e.Mode = proxy.(*ZwlrOutputModeV1)
+ } else {
+ mode := &ZwlrOutputModeV1{}
+ mode.SetContext(i.Context())
+ mode.SetID(objectID)
+ registerServerProxy(i.Context(), mode, objectID)
+ e.Mode = mode
+ }
+ l += 4
+
+ i.modeHandler(e)
+ case 4:
+ if i.enabledHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1EnabledEvent
+ l := 0
+ e.Enabled = int32(client.Uint32(data[l : l+4]))
+ l += 4
+
+ i.enabledHandler(e)
+ case 5:
+ if i.currentModeHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1CurrentModeEvent
+ l := 0
+ e.Mode = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*ZwlrOutputModeV1)
+ l += 4
+
+ i.currentModeHandler(e)
+ case 6:
+ if i.positionHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1PositionEvent
+ l := 0
+ e.X = int32(client.Uint32(data[l : l+4]))
+ l += 4
+ e.Y = int32(client.Uint32(data[l : l+4]))
+ l += 4
+
+ i.positionHandler(e)
+ case 7:
+ if i.transformHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1TransformEvent
+ l := 0
+ e.Transform = int32(client.Uint32(data[l : l+4]))
+ l += 4
+
+ i.transformHandler(e)
+ case 8:
+ if i.scaleHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1ScaleEvent
+ l := 0
+ e.Scale = client.Fixed(data[l : l+4])
+ l += 4
+
+ i.scaleHandler(e)
+ case 9:
+ if i.finishedHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1FinishedEvent
+
+ i.finishedHandler(e)
+ case 10:
+ if i.makeHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1MakeEvent
+ l := 0
+ makeLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Make = client.String(data[l : l+makeLen])
+ l += makeLen
+
+ i.makeHandler(e)
+ case 11:
+ if i.modelHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1ModelEvent
+ l := 0
+ modelLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.Model = client.String(data[l : l+modelLen])
+ l += modelLen
+
+ i.modelHandler(e)
+ case 12:
+ if i.serialNumberHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1SerialNumberEvent
+ l := 0
+ serialNumberLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
+ l += 4
+ e.SerialNumber = client.String(data[l : l+serialNumberLen])
+ l += serialNumberLen
+
+ i.serialNumberHandler(e)
+ case 13:
+ if i.adaptiveSyncHandler == nil {
+ return
+ }
+ var e ZwlrOutputHeadV1AdaptiveSyncEvent
+ l := 0
+ e.State = client.Uint32(data[l : l+4])
+ l += 4
+
+ i.adaptiveSyncHandler(e)
+ }
+}
+
+// ZwlrOutputModeV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZwlrOutputModeV1InterfaceName = "zwlr_output_mode_v1"
+
+// ZwlrOutputModeV1 : output mode
+//
+// This object describes an output mode.
+//
+// Some heads don't support output modes, in which case modes won't be
+// advertised.
+//
+// Properties sent via this interface are applied atomically via the
+// wlr_output_manager.done event. No guarantees are made regarding the order
+// in which properties are sent.
+type ZwlrOutputModeV1 struct {
+ client.BaseProxy
+ sizeHandler ZwlrOutputModeV1SizeHandlerFunc
+ refreshHandler ZwlrOutputModeV1RefreshHandlerFunc
+ preferredHandler ZwlrOutputModeV1PreferredHandlerFunc
+ finishedHandler ZwlrOutputModeV1FinishedHandlerFunc
+}
+
+// NewZwlrOutputModeV1 : output mode
+//
+// This object describes an output mode.
+//
+// Some heads don't support output modes, in which case modes won't be
+// advertised.
+//
+// Properties sent via this interface are applied atomically via the
+// wlr_output_manager.done event. No guarantees are made regarding the order
+// in which properties are sent.
+func NewZwlrOutputModeV1(ctx *client.Context) *ZwlrOutputModeV1 {
+ zwlrOutputModeV1 := &ZwlrOutputModeV1{}
+ ctx.Register(zwlrOutputModeV1)
+ return zwlrOutputModeV1
+}
+
+// Release : destroy the mode object
+//
+// This request indicates that the client will no longer use this mode
+// object.
+func (i *ZwlrOutputModeV1) Release() error {
+ defer i.Context().Unregister(i)
+ const opcode = 0
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// ZwlrOutputModeV1SizeEvent : mode size
+//
+// This event describes the mode size. The size is given in physical
+// hardware units of the output device. This is not necessarily the same as
+// the output size in the global compositor space. For instance, the output
+// may be scaled or transformed.
+type ZwlrOutputModeV1SizeEvent struct {
+ Width int32
+ Height int32
+}
+type ZwlrOutputModeV1SizeHandlerFunc func(ZwlrOutputModeV1SizeEvent)
+
+// SetSizeHandler : sets handler for ZwlrOutputModeV1SizeEvent
+func (i *ZwlrOutputModeV1) SetSizeHandler(f ZwlrOutputModeV1SizeHandlerFunc) {
+ i.sizeHandler = f
+}
+
+// ZwlrOutputModeV1RefreshEvent : mode refresh rate
+//
+// This event describes the mode's fixed vertical refresh rate. It is only
+// sent if the mode has a fixed refresh rate.
+type ZwlrOutputModeV1RefreshEvent struct {
+ Refresh int32
+}
+type ZwlrOutputModeV1RefreshHandlerFunc func(ZwlrOutputModeV1RefreshEvent)
+
+// SetRefreshHandler : sets handler for ZwlrOutputModeV1RefreshEvent
+func (i *ZwlrOutputModeV1) SetRefreshHandler(f ZwlrOutputModeV1RefreshHandlerFunc) {
+ i.refreshHandler = f
+}
+
+// ZwlrOutputModeV1PreferredEvent : mode is preferred
+//
+// This event advertises this mode as preferred.
+type ZwlrOutputModeV1PreferredEvent struct{}
+type ZwlrOutputModeV1PreferredHandlerFunc func(ZwlrOutputModeV1PreferredEvent)
+
+// SetPreferredHandler : sets handler for ZwlrOutputModeV1PreferredEvent
+func (i *ZwlrOutputModeV1) SetPreferredHandler(f ZwlrOutputModeV1PreferredHandlerFunc) {
+ i.preferredHandler = f
+}
+
+// ZwlrOutputModeV1FinishedEvent : the mode has disappeared
+//
+// This event indicates that the mode is no longer available. The mode
+// object becomes inert. Clients should send a destroy request and release
+// any resources associated with it.
+type ZwlrOutputModeV1FinishedEvent struct{}
+type ZwlrOutputModeV1FinishedHandlerFunc func(ZwlrOutputModeV1FinishedEvent)
+
+// SetFinishedHandler : sets handler for ZwlrOutputModeV1FinishedEvent
+func (i *ZwlrOutputModeV1) SetFinishedHandler(f ZwlrOutputModeV1FinishedHandlerFunc) {
+ i.finishedHandler = f
+}
+
+func (i *ZwlrOutputModeV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.sizeHandler == nil {
+ return
+ }
+ var e ZwlrOutputModeV1SizeEvent
+ l := 0
+ e.Width = int32(client.Uint32(data[l : l+4]))
+ l += 4
+ e.Height = int32(client.Uint32(data[l : l+4]))
+ l += 4
+
+ i.sizeHandler(e)
+ case 1:
+ if i.refreshHandler == nil {
+ return
+ }
+ var e ZwlrOutputModeV1RefreshEvent
+ l := 0
+ e.Refresh = int32(client.Uint32(data[l : l+4]))
+ l += 4
+
+ i.refreshHandler(e)
+ case 2:
+ if i.preferredHandler == nil {
+ return
+ }
+ var e ZwlrOutputModeV1PreferredEvent
+
+ i.preferredHandler(e)
+ case 3:
+ if i.finishedHandler == nil {
+ return
+ }
+ var e ZwlrOutputModeV1FinishedEvent
+
+ i.finishedHandler(e)
+ }
+}
+
+// ZwlrOutputConfigurationV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZwlrOutputConfigurationV1InterfaceName = "zwlr_output_configuration_v1"
+
+// ZwlrOutputConfigurationV1 : output configuration
+//
+// This object is used by the client to describe a full output configuration.
+//
+// First, the client needs to setup the output configuration. Each head can
+// be either enabled (and configured) or disabled. It is a protocol error to
+// send two enable_head or disable_head requests with the same head. It is a
+// protocol error to omit a head in a configuration.
+//
+// Then, the client can apply or test the configuration. The compositor will
+// then reply with a succeeded, failed or cancelled event. Finally the client
+// should destroy the configuration object.
+type ZwlrOutputConfigurationV1 struct {
+ client.BaseProxy
+ succeededHandler ZwlrOutputConfigurationV1SucceededHandlerFunc
+ failedHandler ZwlrOutputConfigurationV1FailedHandlerFunc
+ cancelledHandler ZwlrOutputConfigurationV1CancelledHandlerFunc
+}
+
+// NewZwlrOutputConfigurationV1 : output configuration
+//
+// This object is used by the client to describe a full output configuration.
+//
+// First, the client needs to setup the output configuration. Each head can
+// be either enabled (and configured) or disabled. It is a protocol error to
+// send two enable_head or disable_head requests with the same head. It is a
+// protocol error to omit a head in a configuration.
+//
+// Then, the client can apply or test the configuration. The compositor will
+// then reply with a succeeded, failed or cancelled event. Finally the client
+// should destroy the configuration object.
+func NewZwlrOutputConfigurationV1(ctx *client.Context) *ZwlrOutputConfigurationV1 {
+ zwlrOutputConfigurationV1 := &ZwlrOutputConfigurationV1{}
+ ctx.Register(zwlrOutputConfigurationV1)
+ return zwlrOutputConfigurationV1
+}
+
+// EnableHead : enable and configure a head
+//
+// Enable a head. This request creates a head configuration object that can
+// be used to change the head's properties.
+//
+// head: the head to be enabled
+func (i *ZwlrOutputConfigurationV1) EnableHead(head *ZwlrOutputHeadV1) (*ZwlrOutputConfigurationHeadV1, error) {
+ id := NewZwlrOutputConfigurationHeadV1(i.Context())
+ const opcode = 0
+ const _reqBufLen = 8 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], id.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], head.ID())
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return id, err
+}
+
+// DisableHead : disable a head
+//
+// Disable a head.
+//
+// head: the head to be disabled
+func (i *ZwlrOutputConfigurationV1) DisableHead(head *ZwlrOutputHeadV1) error {
+ const opcode = 1
+ const _reqBufLen = 8 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], head.ID())
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Apply : apply the configuration
+//
+// Apply the new output configuration.
+//
+// In case the configuration is successfully applied, there is no guarantee
+// that the new output state matches completely the requested
+// configuration. For instance, a compositor might round the scale if it
+// doesn't support fractional scaling.
+//
+// After this request has been sent, the compositor must respond with an
+// succeeded, failed or cancelled event. Sending a request that isn't the
+// destructor is a protocol error.
+func (i *ZwlrOutputConfigurationV1) Apply() error {
+ const opcode = 2
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Test : test the configuration
+//
+// Test the new output configuration. The configuration won't be applied,
+// but will only be validated.
+//
+// Even if the compositor succeeds to test a configuration, applying it may
+// fail.
+//
+// After this request has been sent, the compositor must respond with an
+// succeeded, failed or cancelled event. Sending a request that isn't the
+// destructor is a protocol error.
+func (i *ZwlrOutputConfigurationV1) Test() error {
+ const opcode = 3
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// Destroy : destroy the output configuration
+//
+// Using this request a client can tell the compositor that it is not going
+// to use the configuration object anymore. Any changes to the outputs
+// that have not been applied will be discarded.
+//
+// This request also destroys wlr_output_configuration_head objects created
+// via this object.
+func (i *ZwlrOutputConfigurationV1) Destroy() error {
+ defer i.Context().Unregister(i)
+ const opcode = 4
+ const _reqBufLen = 8
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+type ZwlrOutputConfigurationV1Error uint32
+
+// ZwlrOutputConfigurationV1Error :
+const (
+ // ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead : head has been configured twice
+ ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead ZwlrOutputConfigurationV1Error = 1
+ // ZwlrOutputConfigurationV1ErrorUnconfiguredHead : head has not been configured
+ ZwlrOutputConfigurationV1ErrorUnconfiguredHead ZwlrOutputConfigurationV1Error = 2
+ // ZwlrOutputConfigurationV1ErrorAlreadyUsed : request sent after configuration has been applied or tested
+ ZwlrOutputConfigurationV1ErrorAlreadyUsed ZwlrOutputConfigurationV1Error = 3
+)
+
+func (e ZwlrOutputConfigurationV1Error) Name() string {
+ switch e {
+ case ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead:
+ return "already_configured_head"
+ case ZwlrOutputConfigurationV1ErrorUnconfiguredHead:
+ return "unconfigured_head"
+ case ZwlrOutputConfigurationV1ErrorAlreadyUsed:
+ return "already_used"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrOutputConfigurationV1Error) Value() string {
+ switch e {
+ case ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead:
+ return "1"
+ case ZwlrOutputConfigurationV1ErrorUnconfiguredHead:
+ return "2"
+ case ZwlrOutputConfigurationV1ErrorAlreadyUsed:
+ return "3"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrOutputConfigurationV1Error) String() string {
+ return e.Name() + "=" + e.Value()
+}
+
+// ZwlrOutputConfigurationV1SucceededEvent : configuration changes succeeded
+//
+// Sent after the compositor has successfully applied the changes or
+// tested them.
+//
+// Upon receiving this event, the client should destroy this object.
+//
+// If the current configuration has changed, events to describe the changes
+// will be sent followed by a wlr_output_manager.done event.
+type ZwlrOutputConfigurationV1SucceededEvent struct{}
+type ZwlrOutputConfigurationV1SucceededHandlerFunc func(ZwlrOutputConfigurationV1SucceededEvent)
+
+// SetSucceededHandler : sets handler for ZwlrOutputConfigurationV1SucceededEvent
+func (i *ZwlrOutputConfigurationV1) SetSucceededHandler(f ZwlrOutputConfigurationV1SucceededHandlerFunc) {
+ i.succeededHandler = f
+}
+
+// ZwlrOutputConfigurationV1FailedEvent : configuration changes failed
+//
+// Sent if the compositor rejects the changes or failed to apply them. The
+// compositor should revert any changes made by the apply request that
+// triggered this event.
+//
+// Upon receiving this event, the client should destroy this object.
+type ZwlrOutputConfigurationV1FailedEvent struct{}
+type ZwlrOutputConfigurationV1FailedHandlerFunc func(ZwlrOutputConfigurationV1FailedEvent)
+
+// SetFailedHandler : sets handler for ZwlrOutputConfigurationV1FailedEvent
+func (i *ZwlrOutputConfigurationV1) SetFailedHandler(f ZwlrOutputConfigurationV1FailedHandlerFunc) {
+ i.failedHandler = f
+}
+
+// ZwlrOutputConfigurationV1CancelledEvent : configuration has been cancelled
+//
+// Sent if the compositor cancels the configuration because the state of an
+// output changed and the client has outdated information (e.g. after an
+// output has been hotplugged).
+//
+// The client can create a new configuration with a newer serial and try
+// again.
+//
+// Upon receiving this event, the client should destroy this object.
+type ZwlrOutputConfigurationV1CancelledEvent struct{}
+type ZwlrOutputConfigurationV1CancelledHandlerFunc func(ZwlrOutputConfigurationV1CancelledEvent)
+
+// SetCancelledHandler : sets handler for ZwlrOutputConfigurationV1CancelledEvent
+func (i *ZwlrOutputConfigurationV1) SetCancelledHandler(f ZwlrOutputConfigurationV1CancelledHandlerFunc) {
+ i.cancelledHandler = f
+}
+
+func (i *ZwlrOutputConfigurationV1) Dispatch(opcode uint32, fd int, data []byte) {
+ switch opcode {
+ case 0:
+ if i.succeededHandler == nil {
+ return
+ }
+ var e ZwlrOutputConfigurationV1SucceededEvent
+
+ i.succeededHandler(e)
+ case 1:
+ if i.failedHandler == nil {
+ return
+ }
+ var e ZwlrOutputConfigurationV1FailedEvent
+
+ i.failedHandler(e)
+ case 2:
+ if i.cancelledHandler == nil {
+ return
+ }
+ var e ZwlrOutputConfigurationV1CancelledEvent
+
+ i.cancelledHandler(e)
+ }
+}
+
+// ZwlrOutputConfigurationHeadV1InterfaceName is the name of the interface as it appears in the [client.Registry].
+// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
+// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
+const ZwlrOutputConfigurationHeadV1InterfaceName = "zwlr_output_configuration_head_v1"
+
+// ZwlrOutputConfigurationHeadV1 : head configuration
+//
+// This object is used by the client to update a single head's configuration.
+//
+// It is a protocol error to set the same property twice.
+type ZwlrOutputConfigurationHeadV1 struct {
+ client.BaseProxy
+}
+
+// NewZwlrOutputConfigurationHeadV1 : head configuration
+//
+// This object is used by the client to update a single head's configuration.
+//
+// It is a protocol error to set the same property twice.
+func NewZwlrOutputConfigurationHeadV1(ctx *client.Context) *ZwlrOutputConfigurationHeadV1 {
+ zwlrOutputConfigurationHeadV1 := &ZwlrOutputConfigurationHeadV1{}
+ ctx.Register(zwlrOutputConfigurationHeadV1)
+ return zwlrOutputConfigurationHeadV1
+}
+
+// SetMode : set the mode
+//
+// This request sets the head's mode.
+func (i *ZwlrOutputConfigurationHeadV1) SetMode(mode *ZwlrOutputModeV1) error {
+ const opcode = 0
+ const _reqBufLen = 8 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], mode.ID())
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetCustomMode : set a custom mode
+//
+// This request assigns a custom mode to the head. The size is given in
+// physical hardware units of the output device. If set to zero, the
+// refresh rate is unspecified.
+//
+// It is a protocol error to set both a mode and a custom mode.
+//
+// width: width of the mode in hardware units
+// height: height of the mode in hardware units
+// refresh: vertical refresh rate in mHz or zero
+func (i *ZwlrOutputConfigurationHeadV1) SetCustomMode(width, height, refresh int32) error {
+ const opcode = 1
+ const _reqBufLen = 8 + 4 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(width))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(height))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(refresh))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetPosition : set the position
+//
+// This request sets the head's position in the global compositor space.
+//
+// x: x position in the global compositor space
+// y: y position in the global compositor space
+func (i *ZwlrOutputConfigurationHeadV1) SetPosition(x, y int32) error {
+ const opcode = 2
+ const _reqBufLen = 8 + 4 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(x))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(y))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetTransform : set the transform
+//
+// This request sets the head's transform.
+func (i *ZwlrOutputConfigurationHeadV1) SetTransform(transform int32) error {
+ const opcode = 3
+ const _reqBufLen = 8 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(transform))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetScale : set the scale
+//
+// This request sets the head's scale.
+func (i *ZwlrOutputConfigurationHeadV1) SetScale(scale float64) error {
+ const opcode = 4
+ const _reqBufLen = 8 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutFixed(_reqBuf[l:l+4], scale)
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+// SetAdaptiveSync : enable/disable adaptive sync
+//
+// This request enables/disables adaptive sync. Adaptive sync is also
+// known as Variable Refresh Rate or VRR.
+func (i *ZwlrOutputConfigurationHeadV1) SetAdaptiveSync(state uint32) error {
+ const opcode = 5
+ const _reqBufLen = 8 + 4
+ var _reqBuf [_reqBufLen]byte
+ l := 0
+ client.PutUint32(_reqBuf[l:4], i.ID())
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
+ l += 4
+ client.PutUint32(_reqBuf[l:l+4], uint32(state))
+ l += 4
+ err := i.Context().WriteMsg(_reqBuf[:], nil)
+ return err
+}
+
+func (i *ZwlrOutputConfigurationHeadV1) Destroy() error {
+ i.Context().Unregister(i)
+ return nil
+}
+
+type ZwlrOutputConfigurationHeadV1Error uint32
+
+// ZwlrOutputConfigurationHeadV1Error :
+const (
+ // ZwlrOutputConfigurationHeadV1ErrorAlreadySet : property has already been set
+ ZwlrOutputConfigurationHeadV1ErrorAlreadySet ZwlrOutputConfigurationHeadV1Error = 1
+ // ZwlrOutputConfigurationHeadV1ErrorInvalidMode : mode doesn't belong to head
+ ZwlrOutputConfigurationHeadV1ErrorInvalidMode ZwlrOutputConfigurationHeadV1Error = 2
+ // ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode : mode is invalid
+ ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode ZwlrOutputConfigurationHeadV1Error = 3
+ // ZwlrOutputConfigurationHeadV1ErrorInvalidTransform : transform value outside enum
+ ZwlrOutputConfigurationHeadV1ErrorInvalidTransform ZwlrOutputConfigurationHeadV1Error = 4
+ // ZwlrOutputConfigurationHeadV1ErrorInvalidScale : scale negative or zero
+ ZwlrOutputConfigurationHeadV1ErrorInvalidScale ZwlrOutputConfigurationHeadV1Error = 5
+ // ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState : invalid enum value used in the set_adaptive_sync request
+ ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState ZwlrOutputConfigurationHeadV1Error = 6
+)
+
+func (e ZwlrOutputConfigurationHeadV1Error) Name() string {
+ switch e {
+ case ZwlrOutputConfigurationHeadV1ErrorAlreadySet:
+ return "already_set"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidMode:
+ return "invalid_mode"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode:
+ return "invalid_custom_mode"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidTransform:
+ return "invalid_transform"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidScale:
+ return "invalid_scale"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState:
+ return "invalid_adaptive_sync_state"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrOutputConfigurationHeadV1Error) Value() string {
+ switch e {
+ case ZwlrOutputConfigurationHeadV1ErrorAlreadySet:
+ return "1"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidMode:
+ return "2"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode:
+ return "3"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidTransform:
+ return "4"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidScale:
+ return "5"
+ case ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState:
+ return "6"
+ default:
+ return ""
+ }
+}
+
+func (e ZwlrOutputConfigurationHeadV1Error) String() string {
+ return e.Name() + "=" + e.Value()
+}
diff --git a/backend/internal/proto/xml/dwl-ipc-unstable-v2.xml b/backend/internal/proto/xml/dwl-ipc-unstable-v2.xml
new file mode 100644
index 00000000..74a212f0
--- /dev/null
+++ b/backend/internal/proto/xml/dwl-ipc-unstable-v2.xml
@@ -0,0 +1,166 @@
+
+
+
+
+ This protocol allows clients to update and get updates from dwl.
+
+ Warning! The protocol described in this file is experimental and
+ backward incompatible changes may be made. Backward compatible
+ changes may be added together with the corresponding interface
+ version bump.
+ Backward incompatible changes are done by bumping the version
+ number in the protocol and interface names and resetting the
+ interface version. Once the protocol is to be declared stable,
+ the 'z' prefix and the version number in the protocol and
+ interface names are removed and the interface version number is
+ reset.
+
+
+
+
+ This interface is exposed as a global in wl_registry.
+
+ Clients can use this interface to get a dwl_ipc_output.
+ After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events.
+ The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client.
+
+
+
+
+ Indicates that the client will not the dwl_ipc_manager object anymore.
+ Objects created through this instance are not affected.
+
+
+
+
+
+ Get a dwl_ipc_outout for the specified wl_output.
+
+
+
+
+
+
+
+ This event is sent after binding.
+ A roundtrip after binding guarantees the client recieved all tags.
+
+
+
+
+
+
+ This event is sent after binding.
+ A roundtrip after binding guarantees the client recieved all layouts.
+
+
+
+
+
+
+
+ Observe and control a dwl output.
+
+ Events are double-buffered:
+ Clients should cache events and redraw when a dwl_ipc_output.frame event is sent.
+
+ Request are not double-buffered:
+ The compositor will update immediately upon request.
+
+
+
+
+
+
+
+
+
+
+ Indicates to that the client no longer needs this dwl_ipc_output.
+
+
+
+
+
+ Indicates the client should hide or show themselves.
+ If the client is visible then hide, if hidden then show.
+
+
+
+
+
+ Indicates if the output is active. Zero is invalid, nonzero is valid.
+
+
+
+
+
+
+ Indicates that a tag has been updated.
+
+
+
+
+
+
+
+
+
+ Indicates a new layout is selected.
+
+
+
+
+
+
+ Indicates the title has changed.
+
+
+
+
+
+
+ Indicates the appid has changed.
+
+
+
+
+
+
+ Indicates the layout has changed. Since layout symbols are dynamic.
+ As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying.
+ You can ignore the zdwl_ipc_output.layout event.
+
+
+
+
+
+
+ Indicates that a sequence of status updates have finished and the client should redraw.
+
+
+
+
+
+
+
+
+
+
+
+ The tags are updated as follows:
+ new_tags = (current_tags AND and_tags) XOR xor_tags
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/internal/proto/xml/ext-workspace-v1.xml b/backend/internal/proto/xml/ext-workspace-v1.xml
new file mode 100644
index 00000000..74074b32
--- /dev/null
+++ b/backend/internal/proto/xml/ext-workspace-v1.xml
@@ -0,0 +1,422 @@
+
+
+
+ Copyright © 2019 Christopher Billington
+ Copyright © 2020 Ilia Bozhinov
+ Copyright © 2022 Victoria Brekenfeld
+
+ Permission to use, copy, modify, distribute, and sell this
+ software and its documentation for any purpose is hereby granted
+ without fee, provided that the above copyright notice appear in
+ all copies and that both that copyright notice and this permission
+ notice appear in supporting documentation, and that the name of
+ the copyright holders not be used in advertising or publicity
+ pertaining to distribution of the software without specific,
+ written prior permission. The copyright holders make no
+ representations about the suitability of this software for any
+ purpose. It is provided "as is" without express or implied
+ warranty.
+
+ THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+ SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+ THIS SOFTWARE.
+
+
+
+
+ Workspaces, also called virtual desktops, are groups of surfaces. A
+ compositor with a concept of workspaces may only show some such groups of
+ surfaces (those of 'active' workspaces) at a time. 'Activating' a
+ workspace is a request for the compositor to display that workspace's
+ surfaces as normal, whereas the compositor may hide or otherwise
+ de-emphasise surfaces that are associated only with 'inactive' workspaces.
+ Workspaces are grouped by which sets of outputs they correspond to, and
+ may contain surfaces only from those outputs. In this way, it is possible
+ for each output to have its own set of workspaces, or for all outputs (or
+ any other arbitrary grouping) to share workspaces. Compositors may
+ optionally conceptually arrange each group of workspaces in an
+ N-dimensional grid.
+
+ The purpose of this protocol is to enable the creation of taskbars and
+ docks by providing them with a list of workspaces and their properties,
+ and allowing them to activate and deactivate workspaces.
+
+ After a client binds the ext_workspace_manager_v1, each workspace will be
+ sent via the workspace event.
+
+
+
+
+ This event is emitted whenever a new workspace group has been created.
+
+ All initial details of the workspace group (outputs) will be
+ sent immediately after this event via the corresponding events in
+ ext_workspace_group_handle_v1 and ext_workspace_handle_v1.
+
+
+
+
+
+
+ This event is emitted whenever a new workspace has been created.
+
+ All initial details of the workspace (name, coordinates, state) will
+ be sent immediately after this event via the corresponding events in
+ ext_workspace_handle_v1.
+
+ Workspaces start off unassigned to any workspace group.
+
+
+
+
+
+
+ The client must send this request after it has finished sending other
+ requests. The compositor must process a series of requests preceding a
+ commit request atomically.
+
+ This allows changes to the workspace properties to be seen as atomic,
+ even if they happen via multiple events, and even if they involve
+ multiple ext_workspace_handle_v1 objects, for example, deactivating one
+ workspace and activating another.
+
+
+
+
+
+ This event is sent after all changes in all workspaces and workspace groups have been
+ sent.
+
+ This allows changes to one or more ext_workspace_group_handle_v1
+ properties and ext_workspace_handle_v1 properties
+ to be seen as atomic, even if they happen via multiple events.
+ In particular, an output moving from one workspace group to
+ another sends an output_enter event and an output_leave event to the two
+ ext_workspace_group_handle_v1 objects in question. The compositor sends
+ the done event only after updating the output information in both
+ workspace groups.
+
+
+
+
+
+ This event indicates that the compositor is done sending events to the
+ ext_workspace_manager_v1. The server will destroy the object
+ immediately after sending this request.
+
+
+
+
+
+ Indicates the client no longer wishes to receive events for new
+ workspace groups. However the compositor may emit further workspace
+ events, until the finished event is emitted. The compositor is expected
+ to send the finished event eventually once the stop request has been processed.
+
+ The client must not send any requests after this one, doing so will raise a wl_display
+ invalid_object error.
+
+
+
+
+
+
+
+ A ext_workspace_group_handle_v1 object represents a workspace group
+ that is assigned a set of outputs and contains a number of workspaces.
+
+ The set of outputs assigned to the workspace group is conveyed to the client via
+ output_enter and output_leave events, and its workspaces are conveyed with
+ workspace events.
+
+ For example, a compositor which has a set of workspaces for each output may
+ advertise a workspace group (and its workspaces) per output, whereas a compositor
+ where a workspace spans all outputs may advertise a single workspace group for all
+ outputs.
+
+
+
+
+
+
+
+
+ This event advertises the capabilities supported by the compositor. If
+ a capability isn't supported, clients should hide or disable the UI
+ elements that expose this functionality. For instance, if the
+ compositor doesn't advertise support for creating workspaces, a button
+ triggering the create_workspace request should not be displayed.
+
+ The compositor will ignore requests it doesn't support. For instance,
+ a compositor which doesn't advertise support for creating workspaces will ignore
+ create_workspace requests.
+
+ Compositors must send this event once after creation of an
+ ext_workspace_group_handle_v1. When the capabilities change, compositors
+ must send this event again.
+
+
+
+
+
+
+ This event is emitted whenever an output is assigned to the workspace
+ group or a new `wl_output` object is bound by the client, which was already
+ assigned to this workspace_group.
+
+
+
+
+
+
+ This event is emitted whenever an output is removed from the workspace
+ group.
+
+
+
+
+
+
+ This event is emitted whenever a workspace is assigned to this group.
+ A workspace may only ever be assigned to a single group at a single point
+ in time, but can be re-assigned during it's lifetime.
+
+
+
+
+
+
+ This event is emitted whenever a workspace is removed from this group.
+
+
+
+
+
+
+ This event is send when the group associated with the ext_workspace_group_handle_v1
+ has been removed. After sending this request the compositor will immediately consider
+ the object inert. Any requests will be ignored except the destroy request.
+ It is guaranteed there won't be any more events referencing this
+ ext_workspace_group_handle_v1.
+
+ The compositor must remove all workspaces belonging to a workspace group
+ via a workspace_leave event before removing the workspace group.
+
+
+
+
+
+ Request that the compositor create a new workspace with the given name
+ and assign it to this group.
+
+ There is no guarantee that the compositor will create a new workspace,
+ or that the created workspace will have the provided name.
+
+
+
+
+
+
+ Destroys the ext_workspace_group_handle_v1 object.
+
+ This request should be send either when the client does not want to
+ use the workspace group object any more or after the removed event to finalize
+ the destruction of the object.
+
+
+
+
+
+
+ A ext_workspace_handle_v1 object represents a workspace that handles a
+ group of surfaces.
+
+ Each workspace has:
+ - a name, conveyed to the client with the name event
+ - potentially an id conveyed with the id event
+ - a list of states, conveyed to the client with the state event
+ - and optionally a set of coordinates, conveyed to the client with the
+ coordinates event
+
+ The client may request that the compositor activate or deactivate the workspace.
+
+ Each workspace can belong to only a single workspace group.
+ Depending on the compositor policy, there might be workspaces with
+ the same name in different workspace groups, but these workspaces are still
+ separate (e.g. one of them might be active while the other is not).
+
+
+
+
+ If this event is emitted, it will be send immediately after the
+ ext_workspace_handle_v1 is created or when an id is assigned to
+ a workspace (at most once during it's lifetime).
+
+ An id will never change during the lifetime of the `ext_workspace_handle_v1`
+ and is guaranteed to be unique during it's lifetime.
+
+ Ids are not human-readable and shouldn't be displayed, use `name` for that purpose.
+
+ Compositors are expected to only send ids for workspaces likely stable across multiple
+ sessions and can be used by clients to store preferences for workspaces. Workspaces without
+ ids should be considered temporary and any data associated with them should be deleted once
+ the respective object is lost.
+
+
+
+
+
+
+ This event is emitted immediately after the ext_workspace_handle_v1 is
+ created and whenever the name of the workspace changes.
+
+ A name is meant to be human-readable and can be displayed to a user.
+ Unlike the id it is neither stable nor unique.
+
+
+
+
+
+
+ This event is used to organize workspaces into an N-dimensional grid
+ within a workspace group, and if supported, is emitted immediately after
+ the ext_workspace_handle_v1 is created and whenever the coordinates of
+ the workspace change. Compositors may not send this event if they do not
+ conceptually arrange workspaces in this way. If compositors simply
+ number workspaces, without any geometric interpretation, they may send
+ 1D coordinates, which clients should not interpret as implying any
+ geometry. Sending an empty array means that the compositor no longer
+ orders the workspace geometrically.
+
+ Coordinates have an arbitrary number of dimensions N with an uint32
+ position along each dimension. By convention if N > 1, the first
+ dimension is X, the second Y, the third Z, and so on. The compositor may
+ chose to utilize these events for a more novel workspace layout
+ convention, however. No guarantee is made about the grid being filled or
+ bounded; there may be a workspace at coordinate 1 and another at
+ coordinate 1000 and none in between. Within a workspace group, however,
+ workspaces must have unique coordinates of equal dimensionality.
+
+
+
+
+
+
+ The different states that a workspace can have.
+
+
+
+
+
+
+ The workspace is not visible in its workspace group, and clients
+ attempting to visualize the compositor workspace state should not
+ display such workspaces.
+
+
+
+
+
+
+ This event is emitted immediately after the ext_workspace_handle_v1 is
+ created and each time the workspace state changes, either because of a
+ compositor action or because of a request in this protocol.
+
+ Missing states convey the opposite meaning, e.g. an unset active bit
+ means the workspace is currently inactive.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This event advertises the capabilities supported by the compositor. If
+ a capability isn't supported, clients should hide or disable the UI
+ elements that expose this functionality. For instance, if the
+ compositor doesn't advertise support for removing workspaces, a button
+ triggering the remove request should not be displayed.
+
+ The compositor will ignore requests it doesn't support. For instance,
+ a compositor which doesn't advertise support for remove will ignore
+ remove requests.
+
+ Compositors must send this event once after creation of an
+ ext_workspace_handle_v1 . When the capabilities change, compositors
+ must send this event again.
+
+
+
+
+
+
+ This event is send when the workspace associated with the ext_workspace_handle_v1
+ has been removed. After sending this request, the compositor will immediately consider
+ the object inert. Any requests will be ignored except the destroy request.
+
+ It is guaranteed there won't be any more events referencing this
+ ext_workspace_handle_v1.
+
+ The compositor must only remove a workspaces not currently belonging to any
+ workspace_group.
+
+
+
+
+
+ Destroys the ext_workspace_handle_v1 object.
+
+ This request should be made either when the client does not want to
+ use the workspace object any more or after the remove event to finalize
+ the destruction of the object.
+
+
+
+
+
+ Request that this workspace be activated.
+
+ There is no guarantee the workspace will be actually activated, and
+ behaviour may be compositor-dependent. For example, activating a
+ workspace may or may not deactivate all other workspaces in the same
+ group.
+
+
+
+
+
+ Request that this workspace be deactivated.
+
+ There is no guarantee the workspace will be actually deactivated.
+
+
+
+
+
+ Requests that this workspace is assigned to the given workspace group.
+
+ There is no guarantee the workspace will be assigned.
+
+
+
+
+
+
+ Request that this workspace be removed.
+
+ There is no guarantee the workspace will be actually removed.
+
+
+
+
diff --git a/backend/internal/proto/xml/wlr-gamma-control-unstable-v1.xml b/backend/internal/proto/xml/wlr-gamma-control-unstable-v1.xml
new file mode 100644
index 00000000..16e0be8b
--- /dev/null
+++ b/backend/internal/proto/xml/wlr-gamma-control-unstable-v1.xml
@@ -0,0 +1,126 @@
+
+
+
+ Copyright © 2015 Giulio camuffo
+ Copyright © 2018 Simon Ser
+
+ Permission to use, copy, modify, distribute, and sell this
+ software and its documentation for any purpose is hereby granted
+ without fee, provided that the above copyright notice appear in
+ all copies and that both that copyright notice and this permission
+ notice appear in supporting documentation, and that the name of
+ the copyright holders not be used in advertising or publicity
+ pertaining to distribution of the software without specific,
+ written prior permission. The copyright holders make no
+ representations about the suitability of this software for any
+ purpose. It is provided "as is" without express or implied
+ warranty.
+
+ THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+ SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+ THIS SOFTWARE.
+
+
+
+ This protocol allows a privileged client to set the gamma tables for
+ outputs.
+
+ Warning! The protocol described in this file is experimental and
+ backward incompatible changes may be made. Backward compatible changes
+ may be added together with the corresponding interface version bump.
+ Backward incompatible changes are done by bumping the version number in
+ the protocol and interface names and resetting the interface version.
+ Once the protocol is to be declared stable, the 'z' prefix and the
+ version number in the protocol and interface names are removed and the
+ interface version number is reset.
+
+
+
+
+ This interface is a manager that allows creating per-output gamma
+ controls.
+
+
+
+
+ Create a gamma control that can be used to adjust gamma tables for the
+ provided output.
+
+
+
+
+
+
+
+ All objects created by the manager will still remain valid, until their
+ appropriate destroy request has been called.
+
+
+
+
+
+
+ This interface allows a client to adjust gamma tables for a particular
+ output.
+
+ The client will receive the gamma size, and will then be able to set gamma
+ tables. At any time the compositor can send a failed event indicating that
+ this object is no longer valid.
+
+ There can only be at most one gamma control object per output, which
+ has exclusive access to this particular output. When the gamma control
+ object is destroyed, the gamma table is restored to its original value.
+
+
+
+
+ Advertise the size of each gamma ramp.
+
+ This event is sent immediately when the gamma control object is created.
+
+
+
+
+
+
+
+
+
+
+ Set the gamma table. The file descriptor can be memory-mapped to provide
+ the raw gamma table, which contains successive gamma ramps for the red,
+ green and blue channels. Each gamma ramp is an array of 16-byte unsigned
+ integers which has the same length as the gamma size.
+
+ The file descriptor data must have the same length as three times the
+ gamma size.
+
+
+
+
+
+
+ This event indicates that the gamma control is no longer valid. This
+ can happen for a number of reasons, including:
+ - The output doesn't support gamma tables
+ - Setting the gamma tables failed
+ - Another client already has exclusive gamma control for this output
+ - The compositor has transferred gamma control to another client
+
+ Upon receiving this event, the client should destroy this object.
+
+
+
+
+
+ Destroys the gamma control object. If the object is still valid, this
+ restores the original gamma tables.
+
+
+
+
diff --git a/backend/internal/proto/xml/wlr-output-management-unstable-v1.xml b/backend/internal/proto/xml/wlr-output-management-unstable-v1.xml
new file mode 100644
index 00000000..541284a8
--- /dev/null
+++ b/backend/internal/proto/xml/wlr-output-management-unstable-v1.xml
@@ -0,0 +1,611 @@
+
+
+
+ Copyright © 2019 Purism SPC
+
+ Permission to use, copy, modify, distribute, and sell this
+ software and its documentation for any purpose is hereby granted
+ without fee, provided that the above copyright notice appear in
+ all copies and that both that copyright notice and this permission
+ notice appear in supporting documentation, and that the name of
+ the copyright holders not be used in advertising or publicity
+ pertaining to distribution of the software without specific,
+ written prior permission. The copyright holders make no
+ representations about the suitability of this software for any
+ purpose. It is provided "as is" without express or implied
+ warranty.
+
+ THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+ SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+ THIS SOFTWARE.
+
+
+
+ This protocol exposes interfaces to obtain and modify output device
+ configuration.
+
+ Warning! The protocol described in this file is experimental and
+ backward incompatible changes may be made. Backward compatible changes
+ may be added together with the corresponding interface version bump.
+ Backward incompatible changes are done by bumping the version number in
+ the protocol and interface names and resetting the interface version.
+ Once the protocol is to be declared stable, the 'z' prefix and the
+ version number in the protocol and interface names are removed and the
+ interface version number is reset.
+
+
+
+
+ This interface is a manager that allows reading and writing the current
+ output device configuration.
+
+ Output devices that display pixels (e.g. a physical monitor or a virtual
+ output in a window) are represented as heads. Heads cannot be created nor
+ destroyed by the client, but they can be enabled or disabled and their
+ properties can be changed. Each head may have one or more available modes.
+
+ Whenever a head appears (e.g. a monitor is plugged in), it will be
+ advertised via the head event. Immediately after the output manager is
+ bound, all current heads are advertised.
+
+ Whenever a head's properties change, the relevant wlr_output_head events
+ will be sent. Not all head properties will be sent: only properties that
+ have changed need to.
+
+ Whenever a head disappears (e.g. a monitor is unplugged), a
+ wlr_output_head.finished event will be sent.
+
+ After one or more heads appear, change or disappear, the done event will
+ be sent. It carries a serial which can be used in a create_configuration
+ request to update heads properties.
+
+ The information obtained from this protocol should only be used for output
+ configuration purposes. This protocol is not designed to be a generic
+ output property advertisement protocol for regular clients. Instead,
+ protocols such as xdg-output should be used.
+
+
+
+
+ This event introduces a new head. This happens whenever a new head
+ appears (e.g. a monitor is plugged in) or after the output manager is
+ bound.
+
+
+
+
+
+
+ This event is sent after all information has been sent after binding to
+ the output manager object and after any subsequent changes. This applies
+ to child head and mode objects as well. In other words, this event is
+ sent whenever a head or mode is created or destroyed and whenever one of
+ their properties has been changed. Not all state is re-sent each time
+ the current configuration changes: only the actual changes are sent.
+
+ This allows changes to the output configuration to be seen as atomic,
+ even if they happen via multiple events.
+
+ A serial is sent to be used in a future create_configuration request.
+
+
+
+
+
+
+ Create a new output configuration object. This allows to update head
+ properties.
+
+
+
+
+
+
+
+ Indicates the client no longer wishes to receive events for output
+ configuration changes. However the compositor may emit further events,
+ until the finished event is emitted.
+
+ The client must not send any more requests after this one.
+
+
+
+
+
+ This event indicates that the compositor is done sending manager events.
+ The compositor will destroy the object immediately after sending this
+ event, so it will become invalid and the client should release any
+ resources associated with it.
+
+
+
+
+
+
+ A head is an output device. The difference between a wl_output object and
+ a head is that heads are advertised even if they are turned off. A head
+ object only advertises properties and cannot be used directly to change
+ them.
+
+ A head has some read-only properties: modes, name, description and
+ physical_size. These cannot be changed by clients.
+
+ Other properties can be updated via a wlr_output_configuration object.
+
+ Properties sent via this interface are applied atomically via the
+ wlr_output_manager.done event. No guarantees are made regarding the order
+ in which properties are sent.
+
+
+
+
+ This event describes the head name.
+
+ The naming convention is compositor defined, but limited to alphanumeric
+ characters and dashes (-). Each name is unique among all wlr_output_head
+ objects, but if a wlr_output_head object is destroyed the same name may
+ be reused later. The names will also remain consistent across sessions
+ with the same hardware and software configuration.
+
+ Examples of names include 'HDMI-A-1', 'WL-1', 'X11-1', etc. However, do
+ not assume that the name is a reflection of an underlying DRM
+ connector, X11 connection, etc.
+
+ If this head matches a wl_output, the wl_output.name event must report
+ the same name.
+
+ The name event is sent after a wlr_output_head object is created. This
+ event is only sent once per object, and the name does not change over
+ the lifetime of the wlr_output_head object.
+
+
+
+
+
+
+ This event describes a human-readable description of the head.
+
+ The description is a UTF-8 string with no convention defined for its
+ contents. Examples might include 'Foocorp 11" Display' or 'Virtual X11
+ output via :1'. However, do not assume that the name is a reflection of
+ the make, model, serial of the underlying DRM connector or the display
+ name of the underlying X11 connection, etc.
+
+ If this head matches a wl_output, the wl_output.description event must
+ report the same name.
+
+ The description event is sent after a wlr_output_head object is created.
+ This event is only sent once per object, and the description does not
+ change over the lifetime of the wlr_output_head object.
+
+
+
+
+
+
+ This event describes the physical size of the head. This event is only
+ sent if the head has a physical size (e.g. is not a projector or a
+ virtual device).
+
+ The physical size event is sent after a wlr_output_head object is created. This
+ event is only sent once per object, and the physical size does not change over
+ the lifetime of the wlr_output_head object.
+
+
+
+
+
+
+
+ This event introduces a mode for this head. It is sent once per
+ supported mode.
+
+
+
+
+
+
+ This event describes whether the head is enabled. A disabled head is not
+ mapped to a region of the global compositor space.
+
+ When a head is disabled, some properties (current_mode, position,
+ transform and scale) are irrelevant.
+
+
+
+
+
+
+ This event describes the mode currently in use for this head. It is only
+ sent if the output is enabled.
+
+
+
+
+
+
+ This events describes the position of the head in the global compositor
+ space. It is only sent if the output is enabled.
+
+
+
+
+
+
+
+ This event describes the transformation currently applied to the head.
+ It is only sent if the output is enabled.
+
+
+
+
+
+
+ This events describes the scale of the head in the global compositor
+ space. It is only sent if the output is enabled.
+
+
+
+
+
+
+ This event indicates that the head is no longer available. The head
+ object becomes inert. Clients should send a destroy request and release
+ any resources associated with it.
+
+
+
+
+
+
+
+ This event describes the manufacturer of the head.
+
+ Together with the model and serial_number events the purpose is to
+ allow clients to recognize heads from previous sessions and for example
+ load head-specific configurations back.
+
+ It is not guaranteed this event will be ever sent. A reason for that
+ can be that the compositor does not have information about the make of
+ the head or the definition of a make is not sensible in the current
+ setup, for example in a virtual session. Clients can still try to
+ identify the head by available information from other events but should
+ be aware that there is an increased risk of false positives.
+
+ If sent, the make event is sent after a wlr_output_head object is
+ created and only sent once per object. The make does not change over
+ the lifetime of the wlr_output_head object.
+
+ It is not recommended to display the make string in UI to users. For
+ that the string provided by the description event should be preferred.
+
+
+
+
+
+
+ This event describes the model of the head.
+
+ Together with the make and serial_number events the purpose is to
+ allow clients to recognize heads from previous sessions and for example
+ load head-specific configurations back.
+
+ It is not guaranteed this event will be ever sent. A reason for that
+ can be that the compositor does not have information about the model of
+ the head or the definition of a model is not sensible in the current
+ setup, for example in a virtual session. Clients can still try to
+ identify the head by available information from other events but should
+ be aware that there is an increased risk of false positives.
+
+ If sent, the model event is sent after a wlr_output_head object is
+ created and only sent once per object. The model does not change over
+ the lifetime of the wlr_output_head object.
+
+ It is not recommended to display the model string in UI to users. For
+ that the string provided by the description event should be preferred.
+
+
+
+
+
+
+ This event describes the serial number of the head.
+
+ Together with the make and model events the purpose is to allow clients
+ to recognize heads from previous sessions and for example load head-
+ specific configurations back.
+
+ It is not guaranteed this event will be ever sent. A reason for that
+ can be that the compositor does not have information about the serial
+ number of the head or the definition of a serial number is not sensible
+ in the current setup. Clients can still try to identify the head by
+ available information from other events but should be aware that there
+ is an increased risk of false positives.
+
+ If sent, the serial number event is sent after a wlr_output_head object
+ is created and only sent once per object. The serial number does not
+ change over the lifetime of the wlr_output_head object.
+
+ It is not recommended to display the serial_number string in UI to
+ users. For that the string provided by the description event should be
+ preferred.
+
+
+
+
+
+
+
+
+ This request indicates that the client will no longer use this head
+ object.
+
+
+
+
+
+
+
+
+
+
+
+
+ This event describes whether adaptive sync is currently enabled for
+ the head or not. Adaptive sync is also known as Variable Refresh
+ Rate or VRR.
+
+
+
+
+
+
+
+ This object describes an output mode.
+
+ Some heads don't support output modes, in which case modes won't be
+ advertised.
+
+ Properties sent via this interface are applied atomically via the
+ wlr_output_manager.done event. No guarantees are made regarding the order
+ in which properties are sent.
+
+
+
+
+ This event describes the mode size. The size is given in physical
+ hardware units of the output device. This is not necessarily the same as
+ the output size in the global compositor space. For instance, the output
+ may be scaled or transformed.
+
+
+
+
+
+
+
+ This event describes the mode's fixed vertical refresh rate. It is only
+ sent if the mode has a fixed refresh rate.
+
+
+
+
+
+
+ This event advertises this mode as preferred.
+
+
+
+
+
+ This event indicates that the mode is no longer available. The mode
+ object becomes inert. Clients should send a destroy request and release
+ any resources associated with it.
+
+
+
+
+
+
+
+ This request indicates that the client will no longer use this mode
+ object.
+
+
+
+
+
+
+ This object is used by the client to describe a full output configuration.
+
+ First, the client needs to setup the output configuration. Each head can
+ be either enabled (and configured) or disabled. It is a protocol error to
+ send two enable_head or disable_head requests with the same head. It is a
+ protocol error to omit a head in a configuration.
+
+ Then, the client can apply or test the configuration. The compositor will
+ then reply with a succeeded, failed or cancelled event. Finally the client
+ should destroy the configuration object.
+
+
+
+
+
+
+
+
+
+
+ Enable a head. This request creates a head configuration object that can
+ be used to change the head's properties.
+
+
+
+
+
+
+
+ Disable a head.
+
+
+
+
+
+
+ Apply the new output configuration.
+
+ In case the configuration is successfully applied, there is no guarantee
+ that the new output state matches completely the requested
+ configuration. For instance, a compositor might round the scale if it
+ doesn't support fractional scaling.
+
+ After this request has been sent, the compositor must respond with an
+ succeeded, failed or cancelled event. Sending a request that isn't the
+ destructor is a protocol error.
+
+
+
+
+
+ Test the new output configuration. The configuration won't be applied,
+ but will only be validated.
+
+ Even if the compositor succeeds to test a configuration, applying it may
+ fail.
+
+ After this request has been sent, the compositor must respond with an
+ succeeded, failed or cancelled event. Sending a request that isn't the
+ destructor is a protocol error.
+
+
+
+
+
+ Sent after the compositor has successfully applied the changes or
+ tested them.
+
+ Upon receiving this event, the client should destroy this object.
+
+ If the current configuration has changed, events to describe the changes
+ will be sent followed by a wlr_output_manager.done event.
+
+
+
+
+
+ Sent if the compositor rejects the changes or failed to apply them. The
+ compositor should revert any changes made by the apply request that
+ triggered this event.
+
+ Upon receiving this event, the client should destroy this object.
+
+
+
+
+
+ Sent if the compositor cancels the configuration because the state of an
+ output changed and the client has outdated information (e.g. after an
+ output has been hotplugged).
+
+ The client can create a new configuration with a newer serial and try
+ again.
+
+ Upon receiving this event, the client should destroy this object.
+
+
+
+
+
+ Using this request a client can tell the compositor that it is not going
+ to use the configuration object anymore. Any changes to the outputs
+ that have not been applied will be discarded.
+
+ This request also destroys wlr_output_configuration_head objects created
+ via this object.
+
+
+
+
+
+
+ This object is used by the client to update a single head's configuration.
+
+ It is a protocol error to set the same property twice.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This request sets the head's mode.
+
+
+
+
+
+
+ This request assigns a custom mode to the head. The size is given in
+ physical hardware units of the output device. If set to zero, the
+ refresh rate is unspecified.
+
+ It is a protocol error to set both a mode and a custom mode.
+
+
+
+
+
+
+
+
+ This request sets the head's position in the global compositor space.
+
+
+
+
+
+
+
+ This request sets the head's transform.
+
+
+
+
+
+
+ This request sets the head's scale.
+
+
+
+
+
+
+
+
+ This request enables/disables adaptive sync. Adaptive sync is also
+ known as Variable Refresh Rate or VRR.
+
+
+
+
+
diff --git a/backend/internal/server/bluez/agent.go b/backend/internal/server/bluez/agent.go
new file mode 100644
index 00000000..9c0bc50f
--- /dev/null
+++ b/backend/internal/server/bluez/agent.go
@@ -0,0 +1,341 @@
+package bluez
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ bluezService = "org.bluez"
+ agentManagerPath = "/org/bluez"
+ agentManagerIface = "org.bluez.AgentManager1"
+ agent1Iface = "org.bluez.Agent1"
+ device1Iface = "org.bluez.Device1"
+ agentPath = "/com/danklinux/bluez/agent"
+ agentCapability = "KeyboardDisplay"
+)
+
+const introspectXML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+
+type BluezAgent struct {
+ conn *dbus.Conn
+ broker PromptBroker
+}
+
+func NewBluezAgent(broker PromptBroker) (*BluezAgent, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("system bus connection failed: %w", err)
+ }
+
+ agent := &BluezAgent{
+ conn: conn,
+ broker: broker,
+ }
+
+ if err := conn.Export(agent, dbus.ObjectPath(agentPath), agent1Iface); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent export failed: %w", err)
+ }
+
+ if err := conn.Export(agent, dbus.ObjectPath(agentPath), "org.freedesktop.DBus.Introspectable"); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("introspection export failed: %w", err)
+ }
+
+ mgr := conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
+ if err := mgr.Call(agentManagerIface+".RegisterAgent", 0, dbus.ObjectPath(agentPath), agentCapability).Err; err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent registration failed: %w", err)
+ }
+
+ if err := mgr.Call(agentManagerIface+".RequestDefaultAgent", 0, dbus.ObjectPath(agentPath)).Err; err != nil {
+ log.Debugf("[BluezAgent] not default agent: %v", err)
+ }
+
+ log.Infof("[BluezAgent] registered at %s with capability %s", agentPath, agentCapability)
+ return agent, nil
+}
+
+func (a *BluezAgent) Close() {
+ if a.conn == nil {
+ return
+ }
+ mgr := a.conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
+ mgr.Call(agentManagerIface+".UnregisterAgent", 0, dbus.ObjectPath(agentPath))
+ a.conn.Close()
+}
+
+func (a *BluezAgent) Release() *dbus.Error {
+ log.Infof("[BluezAgent] Release called")
+ return nil
+}
+
+func (a *BluezAgent) RequestPinCode(device dbus.ObjectPath) (string, *dbus.Error) {
+ log.Infof("[BluezAgent] RequestPinCode: device=%s", device)
+
+ secrets, err := a.promptFor(device, "pin", []string{"pin"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestPinCode failed: %v", err)
+ return "", a.errorFrom(err)
+ }
+
+ pin := secrets["pin"]
+ log.Infof("[BluezAgent] RequestPinCode returning PIN (len=%d)", len(pin))
+ return pin, nil
+}
+
+func (a *BluezAgent) RequestPasskey(device dbus.ObjectPath) (uint32, *dbus.Error) {
+ log.Infof("[BluezAgent] RequestPasskey: device=%s", device)
+
+ secrets, err := a.promptFor(device, "passkey", []string{"passkey"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestPasskey failed: %v", err)
+ return 0, a.errorFrom(err)
+ }
+
+ passkey, err := strconv.ParseUint(secrets["passkey"], 10, 32)
+ if err != nil {
+ log.Warnf("[BluezAgent] invalid passkey format: %v", err)
+ return 0, dbus.MakeFailedError(fmt.Errorf("invalid passkey: %w", err))
+ }
+
+ log.Infof("[BluezAgent] RequestPasskey returning: %d", passkey)
+ return uint32(passkey), nil
+}
+
+func (a *BluezAgent) DisplayPinCode(device dbus.ObjectPath, pincode string) *dbus.Error {
+ log.Infof("[BluezAgent] DisplayPinCode: device=%s, pin=%s", device, pincode)
+
+ _, err := a.promptFor(device, "display-pin", []string{}, &pincode)
+ if err != nil {
+ log.Warnf("[BluezAgent] DisplayPinCode acknowledgment failed: %v", err)
+ }
+
+ return nil
+}
+
+func (a *BluezAgent) DisplayPasskey(device dbus.ObjectPath, passkey uint32, entered uint16) *dbus.Error {
+ log.Infof("[BluezAgent] DisplayPasskey: device=%s, passkey=%06d, entered=%d", device, passkey, entered)
+
+ if entered == 0 {
+ pk := passkey
+ _, err := a.promptFor(device, "display-passkey", []string{}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] DisplayPasskey acknowledgment failed: %v", err)
+ }
+ _ = pk
+ }
+
+ return nil
+}
+
+func (a *BluezAgent) RequestConfirmation(device dbus.ObjectPath, passkey uint32) *dbus.Error {
+ log.Infof("[BluezAgent] RequestConfirmation: device=%s, passkey=%06d", device, passkey)
+
+ secrets, err := a.promptFor(device, "confirm", []string{"decision"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestConfirmation failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] RequestConfirmation rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] RequestConfirmation accepted")
+ return nil
+}
+
+func (a *BluezAgent) RequestAuthorization(device dbus.ObjectPath) *dbus.Error {
+ log.Infof("[BluezAgent] RequestAuthorization: device=%s", device)
+
+ secrets, err := a.promptFor(device, "authorize", []string{"decision"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestAuthorization failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] RequestAuthorization rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] RequestAuthorization accepted")
+ return nil
+}
+
+func (a *BluezAgent) AuthorizeService(device dbus.ObjectPath, uuid string) *dbus.Error {
+ log.Infof("[BluezAgent] AuthorizeService: device=%s, uuid=%s", device, uuid)
+
+ secrets, err := a.promptFor(device, "authorize-service:"+uuid, []string{"decision"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] AuthorizeService failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] AuthorizeService rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] AuthorizeService accepted")
+ return nil
+}
+
+func (a *BluezAgent) Cancel() *dbus.Error {
+ log.Infof("[BluezAgent] Cancel called")
+ return nil
+}
+
+func (a *BluezAgent) Introspect() (string, *dbus.Error) {
+ return introspectXML, nil
+}
+
+func (a *BluezAgent) promptFor(device dbus.ObjectPath, requestType string, fields []string, displayValue *string) (map[string]string, error) {
+ if a.broker == nil {
+ return nil, fmt.Errorf("broker not initialized")
+ }
+
+ deviceName, deviceAddr := a.getDeviceInfo(device)
+ hints := []string{}
+ if displayValue != nil {
+ hints = append(hints, *displayValue)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ var passkey *uint32
+ if requestType == "confirm" || requestType == "display-passkey" {
+ if displayValue != nil {
+ if pk, err := strconv.ParseUint(*displayValue, 10, 32); err == nil {
+ pk32 := uint32(pk)
+ passkey = &pk32
+ }
+ }
+ }
+
+ token, err := a.broker.Ask(ctx, PromptRequest{
+ DevicePath: string(device),
+ DeviceName: deviceName,
+ DeviceAddr: deviceAddr,
+ RequestType: requestType,
+ Fields: fields,
+ Hints: hints,
+ Passkey: passkey,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("prompt creation failed: %w", err)
+ }
+
+ log.Infof("[BluezAgent] waiting for user response (token=%s)", token)
+ reply, err := a.broker.Wait(ctx, token)
+ if err != nil {
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return nil, err
+ }
+ if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return nil, errdefs.ErrSecretPromptCancelled
+ }
+ return nil, err
+ }
+
+ if !reply.Accept && len(fields) > 0 {
+ return nil, errdefs.ErrSecretPromptCancelled
+ }
+
+ return reply.Secrets, nil
+}
+
+func (a *BluezAgent) getDeviceInfo(device dbus.ObjectPath) (string, string) {
+ obj := a.conn.Object(bluezService, device)
+
+ var name, alias, addr string
+
+ nameVar, err := obj.GetProperty(device1Iface + ".Name")
+ if err == nil {
+ if n, ok := nameVar.Value().(string); ok {
+ name = n
+ }
+ }
+
+ aliasVar, err := obj.GetProperty(device1Iface + ".Alias")
+ if err == nil {
+ if a, ok := aliasVar.Value().(string); ok {
+ alias = a
+ }
+ }
+
+ addrVar, err := obj.GetProperty(device1Iface + ".Address")
+ if err == nil {
+ if a, ok := addrVar.Value().(string); ok {
+ addr = a
+ }
+ }
+
+ if alias != "" {
+ return alias, addr
+ }
+ if name != "" {
+ return name, addr
+ }
+ return addr, addr
+}
+
+func (a *BluezAgent) errorFrom(err error) *dbus.Error {
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return dbus.NewError("org.bluez.Error.Canceled", nil)
+ }
+ if errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return dbus.NewError("org.bluez.Error.Canceled", nil)
+ }
+ return dbus.MakeFailedError(err)
+}
diff --git a/backend/internal/server/bluez/broker.go b/backend/internal/server/bluez/broker.go
new file mode 100644
index 00000000..f8177077
--- /dev/null
+++ b/backend/internal/server/bluez/broker.go
@@ -0,0 +1,21 @@
+package bluez
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+)
+
+type PromptBroker interface {
+ Ask(ctx context.Context, req PromptRequest) (token string, err error)
+ Wait(ctx context.Context, token string) (PromptReply, error)
+ Resolve(token string, reply PromptReply) error
+}
+
+func generateToken() (string, error) {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/backend/internal/server/bluez/broker_test.go b/backend/internal/server/bluez/broker_test.go
new file mode 100644
index 00000000..9eebe610
--- /dev/null
+++ b/backend/internal/server/bluez/broker_test.go
@@ -0,0 +1,220 @@
+package bluez
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestSubscriptionBrokerAskWait(t *testing.T) {
+ promptReceived := false
+ broker := NewSubscriptionBroker(func(p PairingPrompt) {
+ promptReceived = true
+ if p.Token == "" {
+ t.Error("expected token to be non-empty")
+ }
+ if p.DeviceName != "TestDevice" {
+ t.Errorf("expected DeviceName=TestDevice, got %s", p.DeviceName)
+ }
+ })
+
+ ctx := context.Background()
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ if token == "" {
+ t.Fatal("expected non-empty token")
+ }
+
+ if !promptReceived {
+ t.Fatal("expected prompt broadcast to be called")
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ }()
+
+ reply, err := broker.Wait(ctx, token)
+ if err != nil {
+ t.Fatalf("Wait failed: %v", err)
+ }
+
+ if reply.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
+ }
+
+ if !reply.Accept {
+ t.Error("expected Accept=true")
+ }
+}
+
+func TestSubscriptionBrokerTimeout(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "passkey",
+ Fields: []string{"passkey"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ _, err = broker.Wait(ctx, token)
+ if err == nil {
+ t.Fatal("expected timeout error")
+ }
+}
+
+func TestSubscriptionBrokerCancel(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx := context.Background()
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+ }()
+
+ _, err = broker.Wait(ctx, token)
+ if err == nil {
+ t.Fatal("expected cancelled error")
+ }
+}
+
+func TestSubscriptionBrokerUnknownToken(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx := context.Background()
+ _, err := broker.Wait(ctx, "invalid-token")
+ if err == nil {
+ t.Fatal("expected error for unknown token")
+ }
+}
+
+func TestGenerateToken(t *testing.T) {
+ token1, err := generateToken()
+ if err != nil {
+ t.Fatalf("generateToken failed: %v", err)
+ }
+
+ token2, err := generateToken()
+ if err != nil {
+ t.Fatalf("generateToken failed: %v", err)
+ }
+
+ if token1 == token2 {
+ t.Error("expected unique tokens")
+ }
+
+ if len(token1) != 32 {
+ t.Errorf("expected token length 32, got %d", len(token1))
+ }
+}
+
+func TestSubscriptionBrokerResolveUnknownToken(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ err := broker.Resolve("unknown-token", PromptReply{
+ Secrets: map[string]string{"test": "value"},
+ })
+ if err == nil {
+ t.Fatal("expected error for unknown token")
+ }
+}
+
+func TestSubscriptionBrokerMultipleRequests(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+ ctx := context.Background()
+
+ req1 := PromptRequest{
+ DevicePath: "/org/bluez/test1",
+ DeviceName: "Device1",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ req2 := PromptRequest{
+ DevicePath: "/org/bluez/test2",
+ DeviceName: "Device2",
+ RequestType: "passkey",
+ Fields: []string{"passkey"},
+ }
+
+ token1, err := broker.Ask(ctx, req1)
+ if err != nil {
+ t.Fatalf("Ask1 failed: %v", err)
+ }
+
+ token2, err := broker.Ask(ctx, req2)
+ if err != nil {
+ t.Fatalf("Ask2 failed: %v", err)
+ }
+
+ if token1 == token2 {
+ t.Error("expected different tokens")
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token1, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ broker.Resolve(token2, PromptReply{
+ Secrets: map[string]string{"passkey": "567890"},
+ Accept: true,
+ })
+ }()
+
+ reply1, err := broker.Wait(ctx, token1)
+ if err != nil {
+ t.Fatalf("Wait1 failed: %v", err)
+ }
+
+ reply2, err := broker.Wait(ctx, token2)
+ if err != nil {
+ t.Fatalf("Wait2 failed: %v", err)
+ }
+
+ if reply1.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply1.Secrets["pin"])
+ }
+
+ if reply2.Secrets["passkey"] != "567890" {
+ t.Errorf("expected passkey=567890, got %s", reply2.Secrets["passkey"])
+ }
+}
diff --git a/backend/internal/server/bluez/handlers.go b/backend/internal/server/bluez/handlers.go
new file mode 100644
index 00000000..1ac8cb27
--- /dev/null
+++ b/backend/internal/server/bluez/handlers.go
@@ -0,0 +1,260 @@
+package bluez
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+type BluetoothEvent struct {
+ Type string `json:"type"`
+ Data BluetoothState `json:"data"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ switch req.Method {
+ case "bluetooth.getState":
+ handleGetState(conn, req, manager)
+ case "bluetooth.startDiscovery":
+ handleStartDiscovery(conn, req, manager)
+ case "bluetooth.stopDiscovery":
+ handleStopDiscovery(conn, req, manager)
+ case "bluetooth.setPowered":
+ handleSetPowered(conn, req, manager)
+ case "bluetooth.pair":
+ handlePairDevice(conn, req, manager)
+ case "bluetooth.connect":
+ handleConnectDevice(conn, req, manager)
+ case "bluetooth.disconnect":
+ handleDisconnectDevice(conn, req, manager)
+ case "bluetooth.remove":
+ handleRemoveDevice(conn, req, manager)
+ case "bluetooth.trust":
+ handleTrustDevice(conn, req, manager)
+ case "bluetooth.untrust":
+ handleUntrustDevice(conn, req, manager)
+ case "bluetooth.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "bluetooth.pairing.submit":
+ handlePairingSubmit(conn, req, manager)
+ case "bluetooth.pairing.cancel":
+ handlePairingCancel(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleStartDiscovery(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.StartDiscovery(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "discovery started"})
+}
+
+func handleStopDiscovery(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.StopDiscovery(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "discovery stopped"})
+}
+
+func handleSetPowered(conn net.Conn, req Request, manager *Manager) {
+ powered, ok := req.Params["powered"].(bool)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'powered' parameter")
+ return
+ }
+
+ if err := manager.SetPowered(powered); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "powered state updated"})
+}
+
+func handlePairDevice(conn net.Conn, req Request, manager *Manager) {
+ devicePath, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
+ return
+ }
+
+ if err := manager.PairDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing initiated"})
+}
+
+func handleConnectDevice(conn net.Conn, req Request, manager *Manager) {
+ devicePath, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
+ return
+ }
+
+ if err := manager.ConnectDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleDisconnectDevice(conn net.Conn, req Request, manager *Manager) {
+ devicePath, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
+ return
+ }
+
+ if err := manager.DisconnectDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleRemoveDevice(conn net.Conn, req Request, manager *Manager) {
+ devicePath, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
+ return
+ }
+
+ if err := manager.RemoveDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device removed"})
+}
+
+func handleTrustDevice(conn net.Conn, req Request, manager *Manager) {
+ devicePath, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
+ return
+ }
+
+ if err := manager.TrustDevice(devicePath, true); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device trusted"})
+}
+
+func handleUntrustDevice(conn net.Conn, req Request, manager *Manager) {
+ devicePath, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'device' parameter")
+ return
+ }
+
+ if err := manager.TrustDevice(devicePath, false); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "device untrusted"})
+}
+
+func handlePairingSubmit(conn net.Conn, req Request, manager *Manager) {
+ token, ok := req.Params["token"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
+ return
+ }
+
+ secretsRaw, ok := req.Params["secrets"].(map[string]interface{})
+ secrets := make(map[string]string)
+ if ok {
+ for k, v := range secretsRaw {
+ if str, ok := v.(string); ok {
+ secrets[k] = str
+ }
+ }
+ }
+
+ accept := false
+ if acceptParam, ok := req.Params["accept"].(bool); ok {
+ accept = acceptParam
+ }
+
+ if err := manager.SubmitPairing(token, secrets, accept); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing response submitted"})
+}
+
+func handlePairingCancel(conn net.Conn, req Request, manager *Manager) {
+ token, ok := req.Params["token"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
+ return
+ }
+
+ if err := manager.CancelPairing(token); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "pairing cancelled"})
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := BluetoothEvent{
+ Type: "state_changed",
+ Data: initialState,
+ }
+
+ if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := BluetoothEvent{
+ Type: "state_changed",
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/backend/internal/server/bluez/handlers_test.go b/backend/internal/server/bluez/handlers_test.go
new file mode 100644
index 00000000..1846eb0b
--- /dev/null
+++ b/backend/internal/server/bluez/handlers_test.go
@@ -0,0 +1,41 @@
+package bluez
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestBrokerIntegration(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+ ctx := context.Background()
+
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ }()
+
+ reply, err := broker.Wait(ctx, token)
+ if err != nil {
+ t.Fatalf("Wait failed: %v", err)
+ }
+
+ if reply.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
+ }
+}
diff --git a/backend/internal/server/bluez/manager.go b/backend/internal/server/bluez/manager.go
new file mode 100644
index 00000000..c4b17500
--- /dev/null
+++ b/backend/internal/server/bluez/manager.go
@@ -0,0 +1,668 @@
+package bluez
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ adapter1Iface = "org.bluez.Adapter1"
+ objectMgrIface = "org.freedesktop.DBus.ObjectManager"
+ propertiesIface = "org.freedesktop.DBus.Properties"
+)
+
+func NewManager() (*Manager, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("system bus connection failed: %w", err)
+ }
+
+ m := &Manager{
+ state: &BluetoothState{
+ Powered: false,
+ Discovering: false,
+ Devices: []Device{},
+ PairedDevices: []Device{},
+ ConnectedDevices: []Device{},
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan BluetoothState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dbusConn: conn,
+ signals: make(chan *dbus.Signal, 256),
+ pairingSubscribers: make(map[string]chan PairingPrompt),
+ pairingSubMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ pendingPairings: make(map[string]bool),
+ eventQueue: make(chan func(), 32),
+ }
+
+ broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
+ m.promptBroker = broker
+
+ adapter, err := m.findAdapter()
+ if err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
+ }
+ m.adapterPath = adapter
+
+ if err := m.initialize(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ if err := m.startAgent(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent start failed: %w", err)
+ }
+
+ if err := m.startSignalPump(); err != nil {
+ m.Close()
+ return nil, err
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.eventWg.Add(1)
+ go m.eventWorker()
+
+ return m, nil
+}
+
+func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+ if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
+ return "", err
+ }
+
+ for path, interfaces := range objects {
+ if _, ok := interfaces[adapter1Iface]; ok {
+ log.Infof("[BluezManager] found adapter: %s", path)
+ return path, nil
+ }
+ }
+
+ return "", fmt.Errorf("no adapter found")
+}
+
+func (m *Manager) initialize() error {
+ if err := m.updateAdapterState(); err != nil {
+ return err
+ }
+
+ if err := m.updateDevices(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *Manager) updateAdapterState() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+
+ poweredVar, err := obj.GetProperty(adapter1Iface + ".Powered")
+ if err != nil {
+ return err
+ }
+ powered, _ := poweredVar.Value().(bool)
+
+ discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering")
+ if err != nil {
+ return err
+ }
+ discovering, _ := discoveringVar.Value().(bool)
+
+ m.stateMutex.Lock()
+ m.state.Powered = powered
+ m.state.Discovering = discovering
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) updateDevices() error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+ if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
+ return err
+ }
+
+ devices := []Device{}
+ paired := []Device{}
+ connected := []Device{}
+
+ for path, interfaces := range objects {
+ devProps, ok := interfaces[device1Iface]
+ if !ok {
+ continue
+ }
+
+ if !strings.HasPrefix(string(path), string(m.adapterPath)+"/") {
+ continue
+ }
+
+ dev := m.deviceFromProps(string(path), devProps)
+ devices = append(devices, dev)
+
+ if dev.Paired {
+ paired = append(paired, dev)
+ }
+ if dev.Connected {
+ connected = append(connected, dev)
+ }
+ }
+
+ m.stateMutex.Lock()
+ m.state.Devices = devices
+ m.state.PairedDevices = paired
+ m.state.ConnectedDevices = connected
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
+ dev := Device{Path: path}
+
+ if v, ok := props["Address"]; ok {
+ if addr, ok := v.Value().(string); ok {
+ dev.Address = addr
+ }
+ }
+ if v, ok := props["Name"]; ok {
+ if name, ok := v.Value().(string); ok {
+ dev.Name = name
+ }
+ }
+ if v, ok := props["Alias"]; ok {
+ if alias, ok := v.Value().(string); ok {
+ dev.Alias = alias
+ }
+ }
+ if v, ok := props["Paired"]; ok {
+ if paired, ok := v.Value().(bool); ok {
+ dev.Paired = paired
+ }
+ }
+ if v, ok := props["Trusted"]; ok {
+ if trusted, ok := v.Value().(bool); ok {
+ dev.Trusted = trusted
+ }
+ }
+ if v, ok := props["Blocked"]; ok {
+ if blocked, ok := v.Value().(bool); ok {
+ dev.Blocked = blocked
+ }
+ }
+ if v, ok := props["Connected"]; ok {
+ if connected, ok := v.Value().(bool); ok {
+ dev.Connected = connected
+ }
+ }
+ if v, ok := props["Class"]; ok {
+ if class, ok := v.Value().(uint32); ok {
+ dev.Class = class
+ }
+ }
+ if v, ok := props["Icon"]; ok {
+ if icon, ok := v.Value().(string); ok {
+ dev.Icon = icon
+ }
+ }
+ if v, ok := props["RSSI"]; ok {
+ if rssi, ok := v.Value().(int16); ok {
+ dev.RSSI = rssi
+ }
+ }
+ if v, ok := props["LegacyPairing"]; ok {
+ if legacy, ok := v.Value().(bool); ok {
+ dev.LegacyPairing = legacy
+ }
+ }
+
+ return dev
+}
+
+func (m *Manager) startAgent() error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ agent, err := NewBluezAgent(m.promptBroker)
+ if err != nil {
+ return err
+ }
+
+ m.agent = agent
+ return nil
+}
+
+func (m *Manager) startSignalPump() error {
+ m.dbusConn.Signal(m.signals)
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(propertiesIface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ return err
+ }
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(objectMgrIface),
+ dbus.WithMatchMember("InterfacesAdded"),
+ ); err != nil {
+ return err
+ }
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(objectMgrIface),
+ dbus.WithMatchMember("InterfacesRemoved"),
+ ); err != nil {
+ return err
+ }
+
+ m.sigWG.Add(1)
+ go func() {
+ defer m.sigWG.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig, ok := <-m.signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ m.handleSignal(sig)
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (m *Manager) handleSignal(sig *dbus.Signal) {
+ switch sig.Name {
+ case propertiesIface + ".PropertiesChanged":
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok {
+ return
+ }
+
+ changed, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ switch iface {
+ case adapter1Iface:
+ if strings.HasPrefix(string(sig.Path), string(m.adapterPath)) {
+ m.handleAdapterPropertiesChanged(changed)
+ }
+ case device1Iface:
+ m.handleDevicePropertiesChanged(sig.Path, changed)
+ }
+
+ case objectMgrIface + ".InterfacesAdded":
+ m.notifySubscribers()
+
+ case objectMgrIface + ".InterfacesRemoved":
+ m.notifySubscribers()
+ }
+}
+
+func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant) {
+ m.stateMutex.Lock()
+ dirty := false
+
+ if v, ok := changed["Powered"]; ok {
+ if powered, ok := v.Value().(bool); ok {
+ m.state.Powered = powered
+ dirty = true
+ }
+ }
+ if v, ok := changed["Discovering"]; ok {
+ if discovering, ok := v.Value().(bool); ok {
+ m.state.Discovering = discovering
+ dirty = true
+ }
+ }
+
+ m.stateMutex.Unlock()
+
+ if dirty {
+ m.notifySubscribers()
+ }
+}
+
+func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
+ pairedVar, hasPaired := changed["Paired"]
+ _, hasConnected := changed["Connected"]
+ _, hasTrusted := changed["Trusted"]
+
+ if hasPaired {
+ if paired, ok := pairedVar.Value().(bool); ok && paired {
+ devicePath := string(path)
+ m.pendingPairingsMux.Lock()
+ wasPending := m.pendingPairings[devicePath]
+ if wasPending {
+ delete(m.pendingPairings, devicePath)
+ }
+ m.pendingPairingsMux.Unlock()
+
+ if wasPending {
+ select {
+ case m.eventQueue <- func() {
+ time.Sleep(300 * time.Millisecond)
+ log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
+ if err := m.ConnectDevice(devicePath); err != nil {
+ log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
+ }
+ }:
+ default:
+ }
+ }
+ }
+ }
+
+ if hasPaired || hasConnected || hasTrusted {
+ select {
+ case m.eventQueue <- func() {
+ time.Sleep(100 * time.Millisecond)
+ m.updateDevices()
+ m.notifySubscribers()
+ }:
+ default:
+ }
+ }
+}
+
+func (m *Manager) eventWorker() {
+ defer m.eventWg.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case event := <-m.eventQueue:
+ event()
+ }
+ }
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 200 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.updateDevices()
+
+ m.subMutex.RLock()
+ if len(m.subscribers) == 0 {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, ¤tState) {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ }
+ m.subMutex.RUnlock()
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) GetState() BluetoothState {
+ return m.snapshotState()
+}
+
+func (m *Manager) snapshotState() BluetoothState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ s := *m.state
+ s.Devices = append([]Device(nil), m.state.Devices...)
+ s.PairedDevices = append([]Device(nil), m.state.PairedDevices...)
+ s.ConnectedDevices = append([]Device(nil), m.state.ConnectedDevices...)
+ return s
+}
+
+func (m *Manager) Subscribe(id string) chan BluetoothState {
+ ch := make(chan BluetoothState, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) SubscribePairing(id string) chan PairingPrompt {
+ ch := make(chan PairingPrompt, 16)
+ m.pairingSubMutex.Lock()
+ m.pairingSubscribers[id] = ch
+ m.pairingSubMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) UnsubscribePairing(id string) {
+ m.pairingSubMutex.Lock()
+ if ch, ok := m.pairingSubscribers[id]; ok {
+ close(ch)
+ delete(m.pairingSubscribers, id)
+ }
+ m.pairingSubMutex.Unlock()
+}
+
+func (m *Manager) broadcastPairingPrompt(prompt PairingPrompt) {
+ m.pairingSubMutex.RLock()
+ defer m.pairingSubMutex.RUnlock()
+
+ for _, ch := range m.pairingSubscribers {
+ select {
+ case ch <- prompt:
+ default:
+ }
+ }
+}
+
+func (m *Manager) SubmitPairing(token string, secrets map[string]string, accept bool) error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return m.promptBroker.Resolve(token, PromptReply{
+ Secrets: secrets,
+ Accept: accept,
+ Cancel: false,
+ })
+}
+
+func (m *Manager) CancelPairing(token string) error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return m.promptBroker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+}
+
+func (m *Manager) StartDiscovery() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".StartDiscovery", 0).Err
+}
+
+func (m *Manager) StopDiscovery() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".StopDiscovery", 0).Err
+}
+
+func (m *Manager) SetPowered(powered bool) error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
+}
+
+func (m *Manager) PairDevice(devicePath string) error {
+ m.pendingPairingsMux.Lock()
+ m.pendingPairings[devicePath] = true
+ m.pendingPairingsMux.Unlock()
+
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ err := obj.Call(device1Iface+".Pair", 0).Err
+
+ if err != nil {
+ m.pendingPairingsMux.Lock()
+ delete(m.pendingPairings, devicePath)
+ m.pendingPairingsMux.Unlock()
+ }
+
+ return err
+}
+
+func (m *Manager) ConnectDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(device1Iface+".Connect", 0).Err
+}
+
+func (m *Manager) DisconnectDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(device1Iface+".Disconnect", 0).Err
+}
+
+func (m *Manager) RemoveDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".RemoveDevice", 0, dbus.ObjectPath(devicePath)).Err
+}
+
+func (m *Manager) TrustDevice(devicePath string, trusted bool) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(propertiesIface+".Set", 0, device1Iface, "Trusted", dbus.MakeVariant(trusted)).Err
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+ m.eventWg.Wait()
+
+ m.sigWG.Wait()
+
+ if m.signals != nil {
+ m.dbusConn.RemoveSignal(m.signals)
+ close(m.signals)
+ }
+
+ if m.agent != nil {
+ m.agent.Close()
+ }
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan BluetoothState)
+ m.subMutex.Unlock()
+
+ m.pairingSubMutex.Lock()
+ for _, ch := range m.pairingSubscribers {
+ close(ch)
+ }
+ m.pairingSubscribers = make(map[string]chan PairingPrompt)
+ m.pairingSubMutex.Unlock()
+
+ if m.dbusConn != nil {
+ m.dbusConn.Close()
+ }
+}
+
+func stateChanged(old, new *BluetoothState) bool {
+ if old.Powered != new.Powered {
+ return true
+ }
+ if old.Discovering != new.Discovering {
+ return true
+ }
+ if len(old.Devices) != len(new.Devices) {
+ return true
+ }
+ if len(old.PairedDevices) != len(new.PairedDevices) {
+ return true
+ }
+ if len(old.ConnectedDevices) != len(new.ConnectedDevices) {
+ return true
+ }
+ for i := range old.Devices {
+ if old.Devices[i].Path != new.Devices[i].Path {
+ return true
+ }
+ if old.Devices[i].Paired != new.Devices[i].Paired {
+ return true
+ }
+ if old.Devices[i].Connected != new.Devices[i].Connected {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/server/bluez/subscription_broker.go b/backend/internal/server/bluez/subscription_broker.go
new file mode 100644
index 00000000..104d9876
--- /dev/null
+++ b/backend/internal/server/bluez/subscription_broker.go
@@ -0,0 +1,99 @@
+package bluez
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+)
+
+type SubscriptionBroker struct {
+ mu sync.RWMutex
+ pending map[string]chan PromptReply
+ requests map[string]PromptRequest
+ broadcastPrompt func(PairingPrompt)
+}
+
+func NewSubscriptionBroker(broadcastPrompt func(PairingPrompt)) PromptBroker {
+ return &SubscriptionBroker{
+ pending: make(map[string]chan PromptReply),
+ requests: make(map[string]PromptRequest),
+ broadcastPrompt: broadcastPrompt,
+ }
+}
+
+func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
+ token, err := generateToken()
+ if err != nil {
+ return "", err
+ }
+
+ replyChan := make(chan PromptReply, 1)
+ b.mu.Lock()
+ b.pending[token] = replyChan
+ b.requests[token] = req
+ b.mu.Unlock()
+
+ if b.broadcastPrompt != nil {
+ prompt := PairingPrompt{
+ Token: token,
+ DevicePath: req.DevicePath,
+ DeviceName: req.DeviceName,
+ DeviceAddr: req.DeviceAddr,
+ RequestType: req.RequestType,
+ Fields: req.Fields,
+ Hints: req.Hints,
+ Passkey: req.Passkey,
+ }
+ b.broadcastPrompt(prompt)
+ }
+
+ return token, nil
+}
+
+func (b *SubscriptionBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
+ b.mu.RLock()
+ replyChan, exists := b.pending[token]
+ b.mu.RUnlock()
+
+ if !exists {
+ return PromptReply{}, fmt.Errorf("unknown token: %s", token)
+ }
+
+ select {
+ case <-ctx.Done():
+ b.cleanup(token)
+ return PromptReply{}, errdefs.ErrSecretPromptTimeout
+ case reply := <-replyChan:
+ b.cleanup(token)
+ if reply.Cancel {
+ return reply, errdefs.ErrSecretPromptCancelled
+ }
+ return reply, nil
+ }
+}
+
+func (b *SubscriptionBroker) Resolve(token string, reply PromptReply) error {
+ b.mu.RLock()
+ replyChan, exists := b.pending[token]
+ b.mu.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("unknown or expired token: %s", token)
+ }
+
+ select {
+ case replyChan <- reply:
+ return nil
+ default:
+ return fmt.Errorf("failed to deliver reply for token: %s", token)
+ }
+}
+
+func (b *SubscriptionBroker) cleanup(token string) {
+ b.mu.Lock()
+ delete(b.pending, token)
+ delete(b.requests, token)
+ b.mu.Unlock()
+}
diff --git a/backend/internal/server/bluez/types.go b/backend/internal/server/bluez/types.go
new file mode 100644
index 00000000..63755873
--- /dev/null
+++ b/backend/internal/server/bluez/types.go
@@ -0,0 +1,80 @@
+package bluez
+
+import (
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+)
+
+type BluetoothState struct {
+ Powered bool `json:"powered"`
+ Discovering bool `json:"discovering"`
+ Devices []Device `json:"devices"`
+ PairedDevices []Device `json:"pairedDevices"`
+ ConnectedDevices []Device `json:"connectedDevices"`
+}
+
+type Device struct {
+ Path string `json:"path"`
+ Address string `json:"address"`
+ Name string `json:"name"`
+ Alias string `json:"alias"`
+ Paired bool `json:"paired"`
+ Trusted bool `json:"trusted"`
+ Blocked bool `json:"blocked"`
+ Connected bool `json:"connected"`
+ Class uint32 `json:"class"`
+ Icon string `json:"icon"`
+ RSSI int16 `json:"rssi"`
+ LegacyPairing bool `json:"legacyPairing"`
+}
+
+type PromptRequest struct {
+ DevicePath string `json:"devicePath"`
+ DeviceName string `json:"deviceName"`
+ DeviceAddr string `json:"deviceAddr"`
+ RequestType string `json:"requestType"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Passkey *uint32 `json:"passkey,omitempty"`
+}
+
+type PromptReply struct {
+ Secrets map[string]string `json:"secrets"`
+ Accept bool `json:"accept"`
+ Cancel bool `json:"cancel"`
+}
+
+type PairingPrompt struct {
+ Token string `json:"token"`
+ DevicePath string `json:"devicePath"`
+ DeviceName string `json:"deviceName"`
+ DeviceAddr string `json:"deviceAddr"`
+ RequestType string `json:"requestType"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Passkey *uint32 `json:"passkey,omitempty"`
+}
+
+type Manager struct {
+ state *BluetoothState
+ stateMutex sync.RWMutex
+ subscribers map[string]chan BluetoothState
+ subMutex sync.RWMutex
+ stopChan chan struct{}
+ dbusConn *dbus.Conn
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ agent *BluezAgent
+ promptBroker PromptBroker
+ pairingSubscribers map[string]chan PairingPrompt
+ pairingSubMutex sync.RWMutex
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *BluetoothState
+ adapterPath dbus.ObjectPath
+ pendingPairings map[string]bool
+ pendingPairingsMux sync.Mutex
+ eventQueue chan func()
+ eventWg sync.WaitGroup
+}
diff --git a/backend/internal/server/bluez/types_test.go b/backend/internal/server/bluez/types_test.go
new file mode 100644
index 00000000..ab5b857d
--- /dev/null
+++ b/backend/internal/server/bluez/types_test.go
@@ -0,0 +1,210 @@
+package bluez
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestBluetoothStateJSON(t *testing.T) {
+ state := BluetoothState{
+ Powered: true,
+ Discovering: false,
+ Devices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Name: "TestDevice",
+ Alias: "My Device",
+ Paired: true,
+ Trusted: false,
+ Connected: true,
+ Class: 0x240418,
+ Icon: "audio-headset",
+ RSSI: -50,
+ },
+ },
+ PairedDevices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Paired: true,
+ },
+ },
+ ConnectedDevices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Connected: true,
+ },
+ },
+ }
+
+ data, err := json.Marshal(state)
+ if err != nil {
+ t.Fatalf("failed to marshal state: %v", err)
+ }
+
+ var decoded BluetoothState
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal state: %v", err)
+ }
+
+ if decoded.Powered != state.Powered {
+ t.Errorf("expected Powered=%v, got %v", state.Powered, decoded.Powered)
+ }
+
+ if len(decoded.Devices) != 1 {
+ t.Fatalf("expected 1 device, got %d", len(decoded.Devices))
+ }
+
+ if decoded.Devices[0].Address != "AA:BB:CC:DD:EE:FF" {
+ t.Errorf("expected address AA:BB:CC:DD:EE:FF, got %s", decoded.Devices[0].Address)
+ }
+}
+
+func TestDeviceJSON(t *testing.T) {
+ device := Device{
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Name: "TestDevice",
+ Alias: "My Device",
+ Paired: true,
+ Trusted: true,
+ Blocked: false,
+ Connected: true,
+ Class: 0x240418,
+ Icon: "audio-headset",
+ RSSI: -50,
+ LegacyPairing: false,
+ }
+
+ data, err := json.Marshal(device)
+ if err != nil {
+ t.Fatalf("failed to marshal device: %v", err)
+ }
+
+ var decoded Device
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal device: %v", err)
+ }
+
+ if decoded.Address != device.Address {
+ t.Errorf("expected Address=%s, got %s", device.Address, decoded.Address)
+ }
+
+ if decoded.Name != device.Name {
+ t.Errorf("expected Name=%s, got %s", device.Name, decoded.Name)
+ }
+
+ if decoded.Paired != device.Paired {
+ t.Errorf("expected Paired=%v, got %v", device.Paired, decoded.Paired)
+ }
+
+ if decoded.RSSI != device.RSSI {
+ t.Errorf("expected RSSI=%d, got %d", device.RSSI, decoded.RSSI)
+ }
+}
+
+func TestPairingPromptJSON(t *testing.T) {
+ passkey := uint32(123456)
+ prompt := PairingPrompt{
+ Token: "test-token",
+ DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ Hints: []string{},
+ Passkey: &passkey,
+ }
+
+ data, err := json.Marshal(prompt)
+ if err != nil {
+ t.Fatalf("failed to marshal prompt: %v", err)
+ }
+
+ var decoded PairingPrompt
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal prompt: %v", err)
+ }
+
+ if decoded.Token != prompt.Token {
+ t.Errorf("expected Token=%s, got %s", prompt.Token, decoded.Token)
+ }
+
+ if decoded.DeviceName != prompt.DeviceName {
+ t.Errorf("expected DeviceName=%s, got %s", prompt.DeviceName, decoded.DeviceName)
+ }
+
+ if decoded.Passkey == nil {
+ t.Fatal("expected non-nil Passkey")
+ }
+
+ if *decoded.Passkey != *prompt.Passkey {
+ t.Errorf("expected Passkey=%d, got %d", *prompt.Passkey, *decoded.Passkey)
+ }
+}
+
+func TestPromptReplyJSON(t *testing.T) {
+ reply := PromptReply{
+ Secrets: map[string]string{
+ "pin": "1234",
+ "passkey": "567890",
+ },
+ Accept: true,
+ Cancel: false,
+ }
+
+ data, err := json.Marshal(reply)
+ if err != nil {
+ t.Fatalf("failed to marshal reply: %v", err)
+ }
+
+ var decoded PromptReply
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal reply: %v", err)
+ }
+
+ if decoded.Secrets["pin"] != reply.Secrets["pin"] {
+ t.Errorf("expected pin=%s, got %s", reply.Secrets["pin"], decoded.Secrets["pin"])
+ }
+
+ if decoded.Accept != reply.Accept {
+ t.Errorf("expected Accept=%v, got %v", reply.Accept, decoded.Accept)
+ }
+}
+
+func TestPromptRequestJSON(t *testing.T) {
+ passkey := uint32(123456)
+ req := PromptRequest{
+ DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ Hints: []string{"hint1", "hint2"},
+ Passkey: &passkey,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ t.Fatalf("failed to marshal request: %v", err)
+ }
+
+ var decoded PromptRequest
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal request: %v", err)
+ }
+
+ if decoded.DevicePath != req.DevicePath {
+ t.Errorf("expected DevicePath=%s, got %s", req.DevicePath, decoded.DevicePath)
+ }
+
+ if decoded.RequestType != req.RequestType {
+ t.Errorf("expected RequestType=%s, got %s", req.RequestType, decoded.RequestType)
+ }
+
+ if len(decoded.Fields) != len(req.Fields) {
+ t.Errorf("expected %d fields, got %d", len(req.Fields), len(decoded.Fields))
+ }
+}
diff --git a/backend/internal/server/brightness/ddc.go b/backend/internal/server/brightness/ddc.go
new file mode 100644
index 00000000..9c0d2352
--- /dev/null
+++ b/backend/internal/server/brightness/ddc.go
@@ -0,0 +1,476 @@
+package brightness
+
+import (
+ "encoding/binary"
+ "fmt"
+ "math"
+ "os"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ I2C_SLAVE = 0x0703
+ DDCCI_ADDR = 0x37
+ DDCCI_VCP_GET = 0x01
+ DDCCI_VCP_SET = 0x03
+ VCP_BRIGHTNESS = 0x10
+ DDC_SOURCE_ADDR = 0x51
+)
+
+func NewDDCBackend() (*DDCBackend, error) {
+ b := &DDCBackend{
+ devices: make(map[string]*ddcDevice),
+ scanInterval: 30 * time.Second,
+ debounceTimers: make(map[string]*time.Timer),
+ debouncePending: make(map[string]ddcPendingSet),
+ }
+
+ if err := b.scanI2CDevices(); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+func (b *DDCBackend) scanI2CDevices() error {
+ b.scanMutex.Lock()
+ lastScan := b.lastScan
+ b.scanMutex.Unlock()
+
+ if time.Since(lastScan) < b.scanInterval {
+ return nil
+ }
+
+ b.scanMutex.Lock()
+ defer b.scanMutex.Unlock()
+
+ if time.Since(b.lastScan) < b.scanInterval {
+ return nil
+ }
+
+ b.devicesMutex.Lock()
+ defer b.devicesMutex.Unlock()
+
+ b.devices = make(map[string]*ddcDevice)
+
+ for i := 0; i < 32; i++ {
+ busPath := fmt.Sprintf("/dev/i2c-%d", i)
+ if _, err := os.Stat(busPath); os.IsNotExist(err) {
+ continue
+ }
+
+ // Skip SMBus, GPU internal buses (e.g. AMDGPU SMU) to prevent GPU hangs
+ if isIgnorableI2CBus(i) {
+ log.Debugf("Skipping ignorable i2c-%d", i)
+ continue
+ }
+
+ dev, err := b.probeDDCDevice(i)
+ if err != nil || dev == nil {
+ continue
+ }
+
+ id := fmt.Sprintf("ddc:i2c-%d", i)
+ dev.id = id
+ b.devices[id] = dev
+ log.Debugf("found DDC device on i2c-%d", i)
+ }
+
+ b.lastScan = time.Now()
+
+ return nil
+}
+
+func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) {
+ busPath := fmt.Sprintf("/dev/i2c-%d", bus)
+
+ fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer syscall.Close(fd)
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
+ return nil, errno
+ }
+
+ dummy := make([]byte, 32)
+ syscall.Read(fd, dummy)
+
+ writebuf := []byte{0x00}
+ n, err := syscall.Write(fd, writebuf)
+ if err == nil && n == len(writebuf) {
+ name := b.getDDCName(bus)
+ dev := &ddcDevice{
+ bus: bus,
+ addr: DDCCI_ADDR,
+ name: name,
+ }
+ b.readInitialBrightness(fd, dev)
+ return dev, nil
+ }
+
+ readbuf := make([]byte, 4)
+ n, err = syscall.Read(fd, readbuf)
+ if err != nil || n == 0 {
+ return nil, fmt.Errorf("x37 unresponsive")
+ }
+
+ name := b.getDDCName(bus)
+
+ dev := &ddcDevice{
+ bus: bus,
+ addr: DDCCI_ADDR,
+ name: name,
+ }
+ b.readInitialBrightness(fd, dev)
+ return dev, nil
+}
+
+func (b *DDCBackend) getDDCName(bus int) string {
+ sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus)
+ data, err := os.ReadFile(sysfsPath)
+ if err != nil {
+ return fmt.Sprintf("I2C-%d", bus)
+ }
+
+ name := strings.TrimSpace(string(data))
+ if name == "" {
+ name = fmt.Sprintf("I2C-%d", bus)
+ }
+
+ return name
+}
+
+func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
+ cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
+ if err != nil {
+ log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
+ return
+ }
+
+ dev.max = cap.max
+ dev.lastBrightness = cap.current
+ log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
+}
+
+func (b *DDCBackend) GetDevices() ([]Device, error) {
+ if err := b.scanI2CDevices(); err != nil {
+ log.Debugf("DDC scan error: %v", err)
+ }
+
+ b.devicesMutex.Lock()
+ defer b.devicesMutex.Unlock()
+
+ devices := make([]Device, 0, len(b.devices))
+
+ for id, dev := range b.devices {
+ devices = append(devices, Device{
+ Class: ClassDDC,
+ ID: id,
+ Name: dev.name,
+ Current: dev.lastBrightness,
+ Max: dev.max,
+ CurrentPercent: dev.lastBrightness,
+ Backend: "ddc",
+ })
+ }
+
+ return devices, nil
+}
+
+func (b *DDCBackend) SetBrightness(id string, value int, exponential bool, callback func()) error {
+ return b.SetBrightnessWithExponent(id, value, exponential, 1.2, callback)
+}
+
+func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential bool, exponent float64, callback func()) error {
+ b.devicesMutex.RLock()
+ _, ok := b.devices[id]
+ b.devicesMutex.RUnlock()
+
+ if !ok {
+ return fmt.Errorf("device not found: %s", id)
+ }
+
+ if value < 0 {
+ return fmt.Errorf("value out of range: %d", value)
+ }
+
+ b.debounceMutex.Lock()
+ defer b.debounceMutex.Unlock()
+
+ b.debouncePending[id] = ddcPendingSet{
+ percent: value,
+ callback: callback,
+ }
+
+ if timer, exists := b.debounceTimers[id]; exists {
+ timer.Reset(200 * time.Millisecond)
+ } else {
+ b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() {
+ b.debounceMutex.Lock()
+ pending, exists := b.debouncePending[id]
+ if exists {
+ delete(b.debouncePending, id)
+ }
+ b.debounceMutex.Unlock()
+
+ if !exists {
+ return
+ }
+
+ err := b.setBrightnessImmediateWithExponent(id, pending.percent)
+ if err != nil {
+ log.Debugf("Failed to set brightness for %s: %v", id, err)
+ }
+
+ if pending.callback != nil {
+ pending.callback()
+ }
+ })
+ }
+
+ return nil
+}
+
+func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) error {
+ b.devicesMutex.RLock()
+ dev, ok := b.devices[id]
+ b.devicesMutex.RUnlock()
+
+ if !ok {
+ return fmt.Errorf("device not found: %s", id)
+ }
+
+ busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus)
+
+ fd, err := syscall.Open(busPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return fmt.Errorf("open i2c device: %w", err)
+ }
+ defer syscall.Close(fd)
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 {
+ return fmt.Errorf("set i2c slave addr: %w", errno)
+ }
+
+ max := dev.max
+ if max == 0 {
+ cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
+ if err != nil {
+ return fmt.Errorf("get current capability: %w", err)
+ }
+ max = cap.max
+ b.devicesMutex.Lock()
+ dev.max = max
+ b.devicesMutex.Unlock()
+ }
+
+ if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil {
+ return fmt.Errorf("set vcp feature: %w", err)
+ }
+
+ log.Debugf("set %s to %d/%d", id, value, max)
+
+ b.devicesMutex.Lock()
+ dev.max = max
+ dev.lastBrightness = value
+ b.devicesMutex.Unlock()
+
+ return nil
+}
+
+func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) {
+ for flushTry := 0; flushTry < 3; flushTry++ {
+ dummy := make([]byte, 32)
+ n, _ := syscall.Read(fd, dummy)
+ if n == 0 {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ data := []byte{
+ DDCCI_VCP_GET,
+ vcp,
+ }
+
+ payload := []byte{
+ DDC_SOURCE_ADDR,
+ byte(len(data)) | 0x80,
+ }
+ payload = append(payload, data...)
+ payload = append(payload, ddcciChecksum(payload))
+
+ n, err := syscall.Write(fd, payload)
+ if err != nil || n != len(payload) {
+ return nil, fmt.Errorf("write i2c: %w", err)
+ }
+
+ time.Sleep(50 * time.Millisecond)
+
+ pollFds := []unix.PollFd{
+ {
+ Fd: int32(fd),
+ Events: unix.POLLIN,
+ },
+ }
+
+ pollTimeout := 200
+ pollResult, err := unix.Poll(pollFds, pollTimeout)
+ if err != nil {
+ return nil, fmt.Errorf("poll i2c: %w", err)
+ }
+ if pollResult == 0 {
+ return nil, fmt.Errorf("poll timeout after %dms", pollTimeout)
+ }
+ if pollFds[0].Revents&unix.POLLIN == 0 {
+ return nil, fmt.Errorf("poll returned but POLLIN not set")
+ }
+
+ response := make([]byte, 12)
+ n, err = syscall.Read(fd, response)
+ if err != nil || n < 8 {
+ return nil, fmt.Errorf("read i2c: %w", err)
+ }
+
+ if response[0] != 0x6E || response[2] != 0x02 {
+ return nil, fmt.Errorf("invalid ddc response")
+ }
+
+ resultCode := response[3]
+ if resultCode != 0x00 {
+ return nil, fmt.Errorf("vcp feature not supported")
+ }
+
+ responseVCP := response[4]
+ if responseVCP != vcp {
+ return nil, fmt.Errorf("vcp mismatch: wanted 0x%02x, got 0x%02x", vcp, responseVCP)
+ }
+
+ maxHigh := response[6]
+ maxLow := response[7]
+ currentHigh := response[8]
+ currentLow := response[9]
+
+ max := int(binary.BigEndian.Uint16([]byte{maxHigh, maxLow}))
+ current := int(binary.BigEndian.Uint16([]byte{currentHigh, currentLow}))
+
+ return &ddcCapability{
+ vcp: vcp,
+ max: max,
+ current: current,
+ }, nil
+}
+
+func ddcciChecksum(payload []byte) byte {
+ sum := byte(0x6E)
+ for _, b := range payload {
+ sum ^= b
+ }
+ return sum
+}
+
+func (b *DDCBackend) setVCPFeature(fd int, vcp byte, value int) error {
+ data := []byte{
+ DDCCI_VCP_SET,
+ vcp,
+ byte(value >> 8),
+ byte(value & 0xFF),
+ }
+
+ payload := []byte{
+ DDC_SOURCE_ADDR,
+ byte(len(data)) | 0x80,
+ }
+ payload = append(payload, data...)
+ payload = append(payload, ddcciChecksum(payload))
+
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 {
+ return fmt.Errorf("set i2c slave for write: %w", errno)
+ }
+
+ n, err := syscall.Write(fd, payload)
+ if err != nil || n != len(payload) {
+ return fmt.Errorf("write i2c: wrote %d/%d: %w", n, len(payload), err)
+ }
+
+ time.Sleep(50 * time.Millisecond)
+
+ return nil
+}
+
+func (b *DDCBackend) percentToValue(percent int, max int, exponential bool) int {
+ const minValue = 1
+
+ if percent == 0 {
+ return minValue
+ }
+
+ usableRange := max - minValue
+ var value int
+
+ if exponential {
+ const exponent = 2.0
+ normalizedPercent := float64(percent) / 100.0
+ hardwarePercent := math.Pow(normalizedPercent, 1.0/exponent)
+ value = minValue + int(math.Round(hardwarePercent*float64(usableRange)))
+ } else {
+ value = minValue + ((percent - 1) * usableRange / 99)
+ }
+
+ if value < minValue {
+ value = minValue
+ }
+ if value > max {
+ value = max
+ }
+
+ return value
+}
+
+func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int {
+ const minValue = 1
+
+ if max == 0 {
+ return 0
+ }
+
+ if value <= minValue {
+ return 1
+ }
+
+ usableRange := max - minValue
+ if usableRange == 0 {
+ return 100
+ }
+
+ var percent int
+
+ if exponential {
+ const exponent = 2.0
+ linearPercent := 1 + ((value - minValue) * 99 / usableRange)
+ normalizedLinear := float64(linearPercent) / 100.0
+ expPercent := math.Pow(normalizedLinear, exponent)
+ percent = int(math.Round(expPercent * 100.0))
+ } else {
+ percent = 1 + ((value - minValue) * 99 / usableRange)
+ }
+
+ if percent > 100 {
+ percent = 100
+ }
+ if percent < 1 {
+ percent = 1
+ }
+
+ return percent
+}
+
+func (b *DDCBackend) Close() {
+}
diff --git a/backend/internal/server/brightness/ddc_filter.go b/backend/internal/server/brightness/ddc_filter.go
new file mode 100644
index 00000000..8d369a2a
--- /dev/null
+++ b/backend/internal/server/brightness/ddc_filter.go
@@ -0,0 +1,135 @@
+package brightness
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+)
+
+// isIgnorableI2CBus checks if an I2C bus should be skipped during DDC probing.
+// Based on ddcutil's sysfs_is_ignorable_i2c_device() (sysfs_base.c:1441)
+func isIgnorableI2CBus(busno int) bool {
+ name := getI2CDeviceSysfsName(busno)
+ driver := getI2CSysfsDriver(busno)
+
+ if name != "" && isIgnorableI2CDeviceName(name, driver) {
+ log.Debugf("i2c-%d: ignoring '%s' (driver: %s)", busno, name, driver)
+ return true
+ }
+
+ // Only probe display adapters (0x03xxxx) and docking stations (0x0axxxx)
+ class := getI2CDeviceSysfsClass(busno)
+ if class != 0 {
+ classHigh := class & 0xFFFF0000
+ ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
+ if ignorable {
+ log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class)
+ }
+ return ignorable
+ }
+
+ return false
+}
+
+// Based on ddcutil's ignorable_i2c_device_sysfs_name() (sysfs_base.c:1408)
+func isIgnorableI2CDeviceName(name, driver string) bool {
+ ignorablePrefixes := []string{
+ "SMBus",
+ "Synopsys DesignWare",
+ "soc:i2cdsi",
+ "smu",
+ "mac-io",
+ "u4",
+ "AMDGPU SMU", // AMD Navi2+ - probing hangs GPU
+ }
+
+ for _, prefix := range ignorablePrefixes {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+
+ // nouveau driver: only nvkm-* buses are valid
+ if driver == "nouveau" && !strings.HasPrefix(name, "nvkm-") {
+ return true
+ }
+
+ return false
+}
+
+// Based on ddcutil's get_i2c_device_sysfs_name() (sysfs_base.c:1175)
+func getI2CDeviceSysfsName(busno int) string {
+ path := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/name", busno)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(data))
+}
+
+// Based on ddcutil's get_i2c_device_sysfs_class() (sysfs_base.c:1380)
+func getI2CDeviceSysfsClass(busno int) uint32 {
+ classPath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/class", busno)
+ data, err := os.ReadFile(classPath)
+ if err != nil {
+ classPath = fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/device/device/class", busno)
+ data, err = os.ReadFile(classPath)
+ if err != nil {
+ return 0
+ }
+ }
+
+ classStr := strings.TrimSpace(string(data))
+ classStr = strings.TrimPrefix(classStr, "0x")
+
+ class, err := strconv.ParseUint(classStr, 16, 32)
+ if err != nil {
+ return 0
+ }
+
+ return uint32(class)
+}
+
+// Based on ddcutil's get_i2c_sysfs_driver_by_busno() (sysfs_base.c:1284)
+func getI2CSysfsDriver(busno int) string {
+ devicePath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno)
+ adapterPath, err := findI2CAdapter(devicePath)
+ if err != nil {
+ return ""
+ }
+
+ driverLink := filepath.Join(adapterPath, "driver")
+ target, err := os.Readlink(driverLink)
+ if err != nil {
+ return ""
+ }
+
+ return filepath.Base(target)
+}
+
+func findI2CAdapter(devicePath string) (string, error) {
+ currentPath := devicePath
+
+ for depth := 0; depth < 10; depth++ {
+ if _, err := os.Stat(filepath.Join(currentPath, "name")); err == nil {
+ return currentPath, nil
+ }
+
+ deviceLink := filepath.Join(currentPath, "device")
+ target, err := os.Readlink(deviceLink)
+ if err != nil {
+ break
+ }
+
+ if !filepath.IsAbs(target) {
+ target = filepath.Join(filepath.Dir(currentPath), target)
+ }
+ currentPath = filepath.Clean(target)
+ }
+
+ return "", fmt.Errorf("could not find adapter for %s", devicePath)
+}
diff --git a/backend/internal/server/brightness/ddc_filter_test.go b/backend/internal/server/brightness/ddc_filter_test.go
new file mode 100644
index 00000000..dc3751f8
--- /dev/null
+++ b/backend/internal/server/brightness/ddc_filter_test.go
@@ -0,0 +1,122 @@
+package brightness
+
+import (
+ "testing"
+)
+
+func TestIsIgnorableI2CDeviceName(t *testing.T) {
+ tests := []struct {
+ name string
+ deviceName string
+ driver string
+ want bool
+ }{
+ {
+ name: "AMDGPU SMU should be ignored",
+ deviceName: "AMDGPU SMU",
+ driver: "amdgpu",
+ want: true,
+ },
+ {
+ name: "SMBus should be ignored",
+ deviceName: "SMBus I801 adapter",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "Synopsys DesignWare should be ignored",
+ deviceName: "Synopsys DesignWare I2C adapter",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "smu prefix should be ignored (Mac G5)",
+ deviceName: "smu-i2c-controller",
+ driver: "",
+ want: true,
+ },
+ {
+ name: "Regular NVIDIA DDC should not be ignored",
+ deviceName: "NVIDIA i2c adapter 1",
+ driver: "nvidia",
+ want: false,
+ },
+ {
+ name: "nouveau nvkm bus should not be ignored",
+ deviceName: "nvkm-0000:01:00.0-bus-0000",
+ driver: "nouveau",
+ want: false,
+ },
+ {
+ name: "nouveau non-nvkm bus should be ignored",
+ deviceName: "nouveau-other-bus",
+ driver: "nouveau",
+ want: true,
+ },
+ {
+ name: "Regular AMD display adapter should not be ignored",
+ deviceName: "AMDGPU DM i2c hw bus 0",
+ driver: "amdgpu",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isIgnorableI2CDeviceName(tt.deviceName, tt.driver)
+ if got != tt.want {
+ t.Errorf("isIgnorableI2CDeviceName(%q, %q) = %v, want %v",
+ tt.deviceName, tt.driver, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestClassFiltering(t *testing.T) {
+ tests := []struct {
+ name string
+ class uint32
+ want bool
+ }{
+ {
+ name: "Display adapter class should not be ignored",
+ class: 0x030000,
+ want: false,
+ },
+ {
+ name: "Docking station class should not be ignored",
+ class: 0x0a0000,
+ want: false,
+ },
+ {
+ name: "Display adapter with subclass should not be ignored",
+ class: 0x030001,
+ want: false,
+ },
+ {
+ name: "SMBus class should be ignored",
+ class: 0x0c0500,
+ want: true,
+ },
+ {
+ name: "Bridge class should be ignored",
+ class: 0x060400,
+ want: true,
+ },
+ {
+ name: "Generic system peripheral should be ignored",
+ class: 0x088000,
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ classHigh := tt.class & 0xFFFF0000
+ ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000)
+ if ignorable != tt.want {
+ t.Errorf("class 0x%08x: ignorable = %v, want %v", tt.class, ignorable, tt.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/server/brightness/ddc_test.go b/backend/internal/server/brightness/ddc_test.go
new file mode 100644
index 00000000..7f85510d
--- /dev/null
+++ b/backend/internal/server/brightness/ddc_test.go
@@ -0,0 +1,135 @@
+package brightness
+
+import (
+ "testing"
+)
+
+func TestDDCBackend_PercentConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ max int
+ percent int
+ wantValue int
+ }{
+ {
+ name: "0% should map to minValue=1",
+ max: 100,
+ percent: 0,
+ wantValue: 1,
+ },
+ {
+ name: "1% should be 1",
+ max: 100,
+ percent: 1,
+ wantValue: 1,
+ },
+ {
+ name: "50% should be ~50",
+ max: 100,
+ percent: 50,
+ wantValue: 50,
+ },
+ {
+ name: "100% should be max",
+ max: 100,
+ percent: 100,
+ wantValue: 100,
+ },
+ }
+
+ b := &DDCBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.percentToValue(tt.percent, tt.max, false)
+ diff := got - tt.wantValue
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > 1 {
+ t.Errorf("percentToValue() = %v, want %v (±1)", got, tt.wantValue)
+ }
+ })
+ }
+}
+
+func TestDDCBackend_ValueToPercent(t *testing.T) {
+ tests := []struct {
+ name string
+ max int
+ value int
+ wantPercent int
+ tolerance int
+ }{
+ {
+ name: "zero value should be 1%",
+ max: 100,
+ value: 0,
+ wantPercent: 1,
+ tolerance: 0,
+ },
+ {
+ name: "min value should be 1%",
+ max: 100,
+ value: 1,
+ wantPercent: 1,
+ tolerance: 0,
+ },
+ {
+ name: "mid value should be ~50%",
+ max: 100,
+ value: 50,
+ wantPercent: 50,
+ tolerance: 2,
+ },
+ {
+ name: "max value should be 100%",
+ max: 100,
+ value: 100,
+ wantPercent: 100,
+ tolerance: 0,
+ },
+ }
+
+ b := &DDCBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.valueToPercent(tt.value, tt.max, false)
+ diff := got - tt.wantPercent
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > tt.tolerance {
+ t.Errorf("valueToPercent() = %v, want %v (±%d)", got, tt.wantPercent, tt.tolerance)
+ }
+ })
+ }
+}
+
+func TestDDCBackend_RoundTrip(t *testing.T) {
+ b := &DDCBackend{}
+
+ tests := []struct {
+ name string
+ max int
+ percent int
+ }{
+ {"1%", 100, 1},
+ {"25%", 100, 25},
+ {"50%", 100, 50},
+ {"75%", 100, 75},
+ {"100%", 100, 100},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ value := b.percentToValue(tt.percent, tt.max, false)
+ gotPercent := b.valueToPercent(value, tt.max, false)
+
+ if diff := tt.percent - gotPercent; diff < -1 || diff > 1 {
+ t.Errorf("round trip failed: wanted %d%%, got %d%% (value=%d)", tt.percent, gotPercent, value)
+ }
+ })
+ }
+}
diff --git a/backend/internal/server/brightness/handlers.go b/backend/internal/server/brightness/handlers.go
new file mode 100644
index 00000000..2eba0d27
--- /dev/null
+++ b/backend/internal/server/brightness/handlers.go
@@ -0,0 +1,163 @@
+package brightness
+
+import (
+ "encoding/json"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req Request, m *Manager) {
+ switch req.Method {
+ case "brightness.getState":
+ handleGetState(conn, req, m)
+ case "brightness.setBrightness":
+ handleSetBrightness(conn, req, m)
+ case "brightness.increment":
+ handleIncrement(conn, req, m)
+ case "brightness.decrement":
+ handleDecrement(conn, req, m)
+ case "brightness.rescan":
+ handleRescan(conn, req, m)
+ case "brightness.subscribe":
+ handleSubscribe(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID.(int), "unknown method: "+req.Method)
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, m *Manager) {
+ state := m.GetState()
+ models.Respond(conn, req.ID.(int), state)
+}
+
+func handleSetBrightness(conn net.Conn, req Request, m *Manager) {
+ var params SetBrightnessParams
+
+ device, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID.(int), "missing or invalid device parameter")
+ return
+ }
+ params.Device = device
+
+ percentFloat, ok := req.Params["percent"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID.(int), "missing or invalid percent parameter")
+ return
+ }
+ params.Percent = int(percentFloat)
+
+ if exponential, ok := req.Params["exponential"].(bool); ok {
+ params.Exponential = exponential
+ }
+
+ exponent := 1.2
+ if exponentFloat, ok := req.Params["exponent"].(float64); ok {
+ params.Exponent = exponentFloat
+ exponent = exponentFloat
+ }
+
+ if err := m.SetBrightnessWithExponent(params.Device, params.Percent, params.Exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID.(int), err.Error())
+ return
+ }
+
+ state := m.GetState()
+ models.Respond(conn, req.ID.(int), state)
+}
+
+func handleIncrement(conn net.Conn, req Request, m *Manager) {
+ device, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID.(int), "missing or invalid device parameter")
+ return
+ }
+
+ step := 10
+ if stepFloat, ok := req.Params["step"].(float64); ok {
+ step = int(stepFloat)
+ }
+
+ exponential := false
+ if expBool, ok := req.Params["exponential"].(bool); ok {
+ exponential = expBool
+ }
+
+ exponent := 1.2
+ if exponentFloat, ok := req.Params["exponent"].(float64); ok {
+ exponent = exponentFloat
+ }
+
+ if err := m.IncrementBrightnessWithExponent(device, step, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID.(int), err.Error())
+ return
+ }
+
+ state := m.GetState()
+ models.Respond(conn, req.ID.(int), state)
+}
+
+func handleDecrement(conn net.Conn, req Request, m *Manager) {
+ device, ok := req.Params["device"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID.(int), "missing or invalid device parameter")
+ return
+ }
+
+ step := 10
+ if stepFloat, ok := req.Params["step"].(float64); ok {
+ step = int(stepFloat)
+ }
+
+ exponential := false
+ if expBool, ok := req.Params["exponential"].(bool); ok {
+ exponential = expBool
+ }
+
+ exponent := 1.2
+ if exponentFloat, ok := req.Params["exponent"].(float64); ok {
+ exponent = exponentFloat
+ }
+
+ if err := m.IncrementBrightnessWithExponent(device, -step, exponential, exponent); err != nil {
+ models.RespondError(conn, req.ID.(int), err.Error())
+ return
+ }
+
+ state := m.GetState()
+ models.Respond(conn, req.ID.(int), state)
+}
+
+func handleRescan(conn net.Conn, req Request, m *Manager) {
+ m.Rescan()
+ state := m.GetState()
+ models.Respond(conn, req.ID.(int), state)
+}
+
+func handleSubscribe(conn net.Conn, req Request, m *Manager) {
+ clientID := "brightness-subscriber"
+ if idStr, ok := req.ID.(string); ok && idStr != "" {
+ clientID = idStr
+ }
+
+ ch := m.Subscribe(clientID)
+ defer m.Unsubscribe(clientID)
+
+ initialState := m.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID.(int),
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range ch {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID.(int),
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/backend/internal/server/brightness/logind.go b/backend/internal/server/brightness/logind.go
new file mode 100644
index 00000000..15d2249d
--- /dev/null
+++ b/backend/internal/server/brightness/logind.go
@@ -0,0 +1,67 @@
+package brightness
+
+import (
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+type DBusConn interface {
+ Object(dest string, path dbus.ObjectPath) dbus.BusObject
+ Close() error
+}
+
+type LogindBackend struct {
+ conn DBusConn
+ connOnce bool
+}
+
+func NewLogindBackend() (*LogindBackend, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("connect to system bus: %w", err)
+ }
+
+ obj := conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto")
+ call := obj.Call("org.freedesktop.DBus.Peer.Ping", 0)
+ if call.Err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("logind not available: %w", call.Err)
+ }
+
+ conn.Close()
+
+ return &LogindBackend{}, nil
+}
+
+func NewLogindBackendWithConn(conn DBusConn) *LogindBackend {
+ return &LogindBackend{
+ conn: conn,
+ }
+}
+
+func (b *LogindBackend) SetBrightness(subsystem, name string, brightness uint32) error {
+ if b.conn == nil {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("connect to system bus: %w", err)
+ }
+ b.conn = conn
+ }
+
+ obj := b.conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto")
+ call := obj.Call("org.freedesktop.login1.Session.SetBrightness", 0, subsystem, name, brightness)
+ if call.Err != nil {
+ return fmt.Errorf("dbus call failed: %w", call.Err)
+ }
+
+ log.Debugf("logind: set %s/%s to %d", subsystem, name, brightness)
+ return nil
+}
+
+func (b *LogindBackend) Close() {
+ if b.conn != nil {
+ b.conn.Close()
+ }
+}
diff --git a/backend/internal/server/brightness/logind_test.go b/backend/internal/server/brightness/logind_test.go
new file mode 100644
index 00000000..ffc1f065
--- /dev/null
+++ b/backend/internal/server/brightness/logind_test.go
@@ -0,0 +1,95 @@
+package brightness
+
+import (
+ "errors"
+ "testing"
+
+ mocks_brightness "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/brightness"
+ mock_dbus "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestLogindBackend_SetBrightness_Success(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "backlight", "nvidia_0", uint32(75)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := backend.SetBrightness("backlight", "nvidia_0", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() error = %v, want nil", err)
+ }
+}
+
+func TestLogindBackend_SetBrightness_DBusError(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ dbusErr := errors.New("permission denied")
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+ Return(&dbus.Call{Err: dbusErr}).
+ Once()
+
+ err := backend.SetBrightness("backlight", "test_device", 50)
+ if err == nil {
+ t.Error("SetBrightness() error = nil, want error")
+ }
+}
+
+func TestLogindBackend_SetBrightness_LEDDevice(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "leds", "test_led", uint32(128)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := backend.SetBrightness("leds", "test_led", 128)
+ if err != nil {
+ t.Errorf("SetBrightness() error = %v, want nil", err)
+ }
+}
+
+func TestLogindBackend_Close(t *testing.T) {
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ backend := NewLogindBackendWithConn(mockConn)
+
+ mockConn.EXPECT().
+ Close().
+ Return(nil).
+ Once()
+
+ backend.Close()
+}
+
+func TestLogindBackend_Close_NilConn(t *testing.T) {
+ backend := &LogindBackend{conn: nil}
+ backend.Close()
+}
diff --git a/backend/internal/server/brightness/manager.go b/backend/internal/server/brightness/manager.go
new file mode 100644
index 00000000..6360bd2d
--- /dev/null
+++ b/backend/internal/server/brightness/manager.go
@@ -0,0 +1,379 @@
+package brightness
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+)
+
+func NewManager() (*Manager, error) {
+ return NewManagerWithOptions(false)
+}
+
+func NewManagerWithOptions(exponential bool) (*Manager, error) {
+ m := &Manager{
+ subscribers: make(map[string]chan State),
+ updateSubscribers: make(map[string]chan DeviceUpdate),
+ stopChan: make(chan struct{}),
+ exponential: exponential,
+ }
+
+ go m.initLogind()
+ go m.initSysfs()
+ go m.initDDC()
+
+ return m, nil
+}
+
+func (m *Manager) initLogind() {
+ log.Debug("Initializing logind backend...")
+ logind, err := NewLogindBackend()
+ if err != nil {
+ log.Infof("Logind backend not available: %v", err)
+ log.Info("Will use direct sysfs access for brightness control")
+ return
+ }
+
+ m.logindBackend = logind
+ m.logindReady = true
+ log.Info("Logind backend initialized - will use for brightness control")
+}
+
+func (m *Manager) initSysfs() {
+ log.Debug("Initializing sysfs backend...")
+ sysfs, err := NewSysfsBackend()
+ if err != nil {
+ log.Warnf("Failed to initialize sysfs backend: %v", err)
+ return
+ }
+
+ devices, err := sysfs.GetDevices()
+ if err != nil {
+ log.Warnf("Failed to get initial sysfs devices: %v", err)
+ m.sysfsBackend = sysfs
+ m.sysfsReady = true
+ m.updateState()
+ return
+ }
+
+ log.Infof("Sysfs backend initialized with %d devices", len(devices))
+ for _, d := range devices {
+ log.Debugf(" - %s: %s (%d%%)", d.ID, d.Name, d.CurrentPercent)
+ }
+
+ m.sysfsBackend = sysfs
+ m.sysfsReady = true
+ m.updateState()
+}
+
+func (m *Manager) initDDC() {
+ ddc, err := NewDDCBackend()
+ if err != nil {
+ log.Debugf("Failed to initialize DDC backend: %v", err)
+ return
+ }
+
+ m.ddcBackend = ddc
+ m.ddcReady = true
+ log.Info("DDC backend initialized")
+
+ m.updateState()
+}
+
+func (m *Manager) Rescan() {
+ log.Debug("Rescanning brightness devices...")
+ m.updateState()
+}
+
+func sortDevices(devices []Device) {
+ sort.Slice(devices, func(i, j int) bool {
+ classOrder := map[DeviceClass]int{
+ ClassBacklight: 0,
+ ClassDDC: 1,
+ ClassLED: 2,
+ }
+
+ orderI := classOrder[devices[i].Class]
+ orderJ := classOrder[devices[j].Class]
+
+ if orderI != orderJ {
+ return orderI < orderJ
+ }
+
+ return devices[i].Name < devices[j].Name
+ })
+}
+
+func stateChanged(old, new State) bool {
+ if len(old.Devices) != len(new.Devices) {
+ return true
+ }
+
+ oldMap := make(map[string]Device)
+ for _, d := range old.Devices {
+ oldMap[d.ID] = d
+ }
+
+ for _, newDev := range new.Devices {
+ oldDev, exists := oldMap[newDev.ID]
+ if !exists {
+ return true
+ }
+ if oldDev.Current != newDev.Current || oldDev.Max != newDev.Max {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *Manager) updateState() {
+ allDevices := make([]Device, 0)
+
+ if m.sysfsReady && m.sysfsBackend != nil {
+ devices, err := m.sysfsBackend.GetDevices()
+ if err != nil {
+ log.Debugf("Failed to get sysfs devices: %v", err)
+ }
+ if err == nil {
+ allDevices = append(allDevices, devices...)
+ }
+ }
+
+ if m.ddcReady && m.ddcBackend != nil {
+ devices, err := m.ddcBackend.GetDevices()
+ if err != nil {
+ log.Debugf("Failed to get DDC devices: %v", err)
+ }
+ if err == nil {
+ allDevices = append(allDevices, devices...)
+ }
+ }
+
+ sortDevices(allDevices)
+
+ m.stateMutex.Lock()
+ oldState := m.state
+ newState := State{Devices: allDevices}
+
+ if !stateChanged(oldState, newState) {
+ m.stateMutex.Unlock()
+ return
+ }
+
+ m.state = newState
+ m.stateMutex.Unlock()
+ log.Debugf("State changed, notifying subscribers")
+ m.NotifySubscribers()
+}
+
+func (m *Manager) SetBrightness(deviceID string, percent int) error {
+ return m.SetBrightnessWithMode(deviceID, percent, m.exponential)
+}
+
+func (m *Manager) SetBrightnessWithMode(deviceID string, percent int, exponential bool) error {
+ return m.SetBrightnessWithExponent(deviceID, percent, exponential, 1.2)
+}
+
+func (m *Manager) SetBrightnessWithExponent(deviceID string, percent int, exponential bool, exponent float64) error {
+ if percent < 0 {
+ return fmt.Errorf("percent out of range: %d", percent)
+ }
+
+ log.Debugf("SetBrightness: %s to %d%%", deviceID, percent)
+
+ m.stateMutex.Lock()
+ currentState := m.state
+ var found bool
+ var deviceClass DeviceClass
+ var deviceIndex int
+
+ log.Debugf("Current state has %d devices", len(currentState.Devices))
+
+ for i, dev := range currentState.Devices {
+ if dev.ID == deviceID {
+ found = true
+ deviceClass = dev.Class
+ deviceIndex = i
+ break
+ }
+ }
+
+ if !found {
+ m.stateMutex.Unlock()
+ log.Debugf("Device not found in state: %s", deviceID)
+ return fmt.Errorf("device not found: %s", deviceID)
+ }
+
+ newDevices := make([]Device, len(currentState.Devices))
+ copy(newDevices, currentState.Devices)
+ newDevices[deviceIndex].CurrentPercent = percent
+ m.state = State{Devices: newDevices}
+ m.stateMutex.Unlock()
+
+ var err error
+ if deviceClass == ClassDDC {
+ log.Debugf("Calling DDC backend for %s", deviceID)
+ err = m.ddcBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, func() {
+ m.updateState()
+ m.debouncedBroadcast(deviceID)
+ })
+ } else if m.logindReady && m.logindBackend != nil {
+ log.Debugf("Calling logind backend for %s", deviceID)
+ err = m.setViaSysfsWithLogindWithExponent(deviceID, percent, exponential, exponent)
+ } else {
+ log.Debugf("Calling sysfs backend for %s", deviceID)
+ err = m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ if err != nil {
+ m.updateState()
+ return fmt.Errorf("failed to set brightness: %w", err)
+ }
+
+ if deviceClass != ClassDDC {
+ log.Debugf("Queueing broadcast for %s", deviceID)
+ m.debouncedBroadcast(deviceID)
+ }
+ return nil
+}
+
+func (m *Manager) IncrementBrightness(deviceID string, step int) error {
+ return m.IncrementBrightnessWithMode(deviceID, step, m.exponential)
+}
+
+func (m *Manager) IncrementBrightnessWithMode(deviceID string, step int, exponential bool) error {
+ return m.IncrementBrightnessWithExponent(deviceID, step, exponential, 1.2)
+}
+
+func (m *Manager) IncrementBrightnessWithExponent(deviceID string, step int, exponential bool, exponent float64) error {
+ m.stateMutex.RLock()
+ currentState := m.state
+ m.stateMutex.RUnlock()
+
+ var currentPercent int
+ var found bool
+
+ for _, dev := range currentState.Devices {
+ if dev.ID == deviceID {
+ currentPercent = dev.CurrentPercent
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ return fmt.Errorf("device not found: %s", deviceID)
+ }
+
+ newPercent := currentPercent + step
+ if newPercent > 100 {
+ newPercent = 100
+ }
+ if newPercent < 0 {
+ newPercent = 0
+ }
+
+ return m.SetBrightnessWithExponent(deviceID, newPercent, exponential, exponent)
+}
+
+func (m *Manager) DecrementBrightness(deviceID string, step int) error {
+ return m.IncrementBrightness(deviceID, -step)
+}
+
+func (m *Manager) setViaSysfsWithLogindWithExponent(deviceID string, percent int, exponential bool, exponent float64) error {
+ parts := strings.SplitN(deviceID, ":", 2)
+ if len(parts) != 2 {
+ return fmt.Errorf("invalid device id: %s", deviceID)
+ }
+
+ subsystem := parts[0]
+ name := parts[1]
+
+ dev, err := m.sysfsBackend.GetDevice(deviceID)
+ if err != nil {
+ return err
+ }
+
+ value := m.sysfsBackend.PercentToValueWithExponent(percent, dev, exponential, exponent)
+
+ if m.logindBackend == nil {
+ return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ err = m.logindBackend.SetBrightness(subsystem, name, uint32(value))
+ if err != nil {
+ log.Debugf("logind SetBrightness failed, falling back to direct sysfs: %v", err)
+ return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent)
+ }
+
+ log.Debugf("set %s to %d%% (%d/%d) via logind", deviceID, percent, value, dev.maxBrightness)
+ return nil
+}
+
+func (m *Manager) debouncedBroadcast(deviceID string) {
+ m.broadcastMutex.Lock()
+ defer m.broadcastMutex.Unlock()
+
+ m.broadcastPending = true
+ m.pendingDeviceID = deviceID
+
+ if m.broadcastTimer == nil {
+ m.broadcastTimer = time.AfterFunc(150*time.Millisecond, func() {
+ m.broadcastMutex.Lock()
+ pending := m.broadcastPending
+ deviceID := m.pendingDeviceID
+ m.broadcastPending = false
+ m.pendingDeviceID = ""
+ m.broadcastMutex.Unlock()
+
+ if !pending || deviceID == "" {
+ return
+ }
+
+ m.broadcastDeviceUpdate(deviceID)
+ })
+ } else {
+ m.broadcastTimer.Reset(150 * time.Millisecond)
+ }
+}
+
+func (m *Manager) broadcastDeviceUpdate(deviceID string) {
+ m.stateMutex.RLock()
+ var targetDevice *Device
+ for _, dev := range m.state.Devices {
+ if dev.ID == deviceID {
+ devCopy := dev
+ targetDevice = &devCopy
+ break
+ }
+ }
+ m.stateMutex.RUnlock()
+
+ if targetDevice == nil {
+ log.Debugf("Device not found for broadcast: %s", deviceID)
+ return
+ }
+
+ update := DeviceUpdate{Device: *targetDevice}
+
+ m.subMutex.RLock()
+ defer m.subMutex.RUnlock()
+
+ if len(m.updateSubscribers) == 0 {
+ log.Debugf("No update subscribers for device: %s", deviceID)
+ return
+ }
+
+ log.Debugf("Broadcasting device update: %s at %d%%", deviceID, targetDevice.CurrentPercent)
+
+ for _, ch := range m.updateSubscribers {
+ select {
+ case ch <- update:
+ default:
+ }
+ }
+}
diff --git a/backend/internal/server/brightness/manager_test.go b/backend/internal/server/brightness/manager_test.go
new file mode 100644
index 00000000..a8ae0f8c
--- /dev/null
+++ b/backend/internal/server/brightness/manager_test.go
@@ -0,0 +1,11 @@
+package brightness
+
+import (
+ "testing"
+)
+
+// Manager tests can be added here as needed
+func TestManager_Placeholder(t *testing.T) {
+ // Placeholder test to keep the test file valid
+ t.Skip("No tests implemented yet")
+}
diff --git a/backend/internal/server/brightness/sysfs.go b/backend/internal/server/brightness/sysfs.go
new file mode 100644
index 00000000..fb6a0162
--- /dev/null
+++ b/backend/internal/server/brightness/sysfs.go
@@ -0,0 +1,272 @@
+package brightness
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+)
+
+func NewSysfsBackend() (*SysfsBackend, error) {
+ b := &SysfsBackend{
+ basePath: "/sys/class",
+ classes: []string{"backlight", "leds"},
+ deviceCache: make(map[string]*sysfsDevice),
+ }
+
+ if err := b.scanDevices(); err != nil {
+ return nil, err
+ }
+
+ return b, nil
+}
+
+func (b *SysfsBackend) scanDevices() error {
+ b.deviceCacheMutex.Lock()
+ defer b.deviceCacheMutex.Unlock()
+
+ for _, class := range b.classes {
+ classPath := filepath.Join(b.basePath, class)
+ entries, err := os.ReadDir(classPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return fmt.Errorf("read %s: %w", classPath, err)
+ }
+
+ for _, entry := range entries {
+ devicePath := filepath.Join(classPath, entry.Name())
+
+ stat, err := os.Stat(devicePath)
+ if err != nil || !stat.IsDir() {
+ continue
+ }
+ maxPath := filepath.Join(devicePath, "max_brightness")
+
+ maxData, err := os.ReadFile(maxPath)
+ if err != nil {
+ log.Debugf("skip %s/%s: no max_brightness", class, entry.Name())
+ continue
+ }
+
+ maxBrightness, err := strconv.Atoi(strings.TrimSpace(string(maxData)))
+ if err != nil || maxBrightness <= 0 {
+ log.Debugf("skip %s/%s: invalid max_brightness", class, entry.Name())
+ continue
+ }
+
+ deviceClass := ClassBacklight
+ minValue := 1
+ if class == "leds" {
+ deviceClass = ClassLED
+ minValue = 0
+ }
+
+ deviceID := fmt.Sprintf("%s:%s", class, entry.Name())
+ b.deviceCache[deviceID] = &sysfsDevice{
+ class: deviceClass,
+ id: deviceID,
+ name: entry.Name(),
+ maxBrightness: maxBrightness,
+ minValue: minValue,
+ }
+
+ log.Debugf("found %s device: %s (max=%d)", class, entry.Name(), maxBrightness)
+ }
+ }
+
+ return nil
+}
+
+func shouldSuppressDevice(name string) bool {
+ if strings.HasSuffix(name, "::lan") {
+ return true
+ }
+
+ keyboardLEDs := []string{
+ "::scrolllock",
+ "::capslock",
+ "::numlock",
+ "::kana",
+ "::compose",
+ }
+
+ for _, suffix := range keyboardLEDs {
+ if strings.HasSuffix(name, suffix) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (b *SysfsBackend) GetDevices() ([]Device, error) {
+ b.deviceCacheMutex.RLock()
+ defer b.deviceCacheMutex.RUnlock()
+
+ devices := make([]Device, 0, len(b.deviceCache))
+
+ for _, dev := range b.deviceCache {
+ if shouldSuppressDevice(dev.name) {
+ continue
+ }
+
+ parts := strings.SplitN(dev.id, ":", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ class := parts[0]
+ name := parts[1]
+
+ devicePath := filepath.Join(b.basePath, class, name)
+ brightnessPath := filepath.Join(devicePath, "brightness")
+
+ brightnessData, err := os.ReadFile(brightnessPath)
+ if err != nil {
+ log.Debugf("failed to read brightness for %s: %v", dev.id, err)
+ continue
+ }
+
+ current, err := strconv.Atoi(strings.TrimSpace(string(brightnessData)))
+ if err != nil {
+ log.Debugf("failed to parse brightness for %s: %v", dev.id, err)
+ continue
+ }
+
+ percent := b.ValueToPercent(current, dev, false)
+
+ devices = append(devices, Device{
+ Class: dev.class,
+ ID: dev.id,
+ Name: dev.name,
+ Current: current,
+ Max: dev.maxBrightness,
+ CurrentPercent: percent,
+ Backend: "sysfs",
+ })
+ }
+
+ return devices, nil
+}
+
+func (b *SysfsBackend) GetDevice(id string) (*sysfsDevice, error) {
+ b.deviceCacheMutex.RLock()
+ defer b.deviceCacheMutex.RUnlock()
+
+ dev, ok := b.deviceCache[id]
+ if !ok {
+ return nil, fmt.Errorf("device not found: %s", id)
+ }
+
+ return dev, nil
+}
+
+func (b *SysfsBackend) SetBrightness(id string, percent int, exponential bool) error {
+ return b.SetBrightnessWithExponent(id, percent, exponential, 1.2)
+}
+
+func (b *SysfsBackend) SetBrightnessWithExponent(id string, percent int, exponential bool, exponent float64) error {
+ dev, err := b.GetDevice(id)
+ if err != nil {
+ return err
+ }
+
+ if percent < 0 {
+ return fmt.Errorf("percent out of range: %d", percent)
+ }
+
+ value := b.PercentToValueWithExponent(percent, dev, exponential, exponent)
+
+ parts := strings.SplitN(id, ":", 2)
+ if len(parts) != 2 {
+ return fmt.Errorf("invalid device id: %s", id)
+ }
+
+ class := parts[0]
+ name := parts[1]
+
+ devicePath := filepath.Join(b.basePath, class, name)
+ brightnessPath := filepath.Join(devicePath, "brightness")
+
+ data := []byte(fmt.Sprintf("%d", value))
+ if err := os.WriteFile(brightnessPath, data, 0644); err != nil {
+ return fmt.Errorf("write brightness: %w", err)
+ }
+
+ log.Debugf("set %s to %d%% (%d/%d) via direct sysfs", id, percent, value, dev.maxBrightness)
+
+ return nil
+}
+
+func (b *SysfsBackend) PercentToValue(percent int, dev *sysfsDevice, exponential bool) int {
+ return b.PercentToValueWithExponent(percent, dev, exponential, 1.2)
+}
+
+func (b *SysfsBackend) PercentToValueWithExponent(percent int, dev *sysfsDevice, exponential bool, exponent float64) int {
+ if percent == 0 {
+ return dev.minValue
+ }
+
+ usableRange := dev.maxBrightness - dev.minValue
+ var value int
+
+ if exponential {
+ normalizedPercent := float64(percent-1) / 99.0
+ hardwarePercent := math.Pow(normalizedPercent, exponent)
+ value = dev.minValue + int(math.Round(hardwarePercent*float64(usableRange)))
+ } else {
+ value = dev.minValue + ((percent - 1) * usableRange / 99)
+ }
+
+ if value < dev.minValue {
+ value = dev.minValue
+ }
+ if value > dev.maxBrightness {
+ value = dev.maxBrightness
+ }
+
+ return value
+}
+
+func (b *SysfsBackend) ValueToPercent(value int, dev *sysfsDevice, exponential bool) int {
+ return b.ValueToPercentWithExponent(value, dev, exponential, 1.2)
+}
+
+func (b *SysfsBackend) ValueToPercentWithExponent(value int, dev *sysfsDevice, exponential bool, exponent float64) int {
+ if value <= dev.minValue {
+ if dev.minValue == 0 && value == 0 {
+ return 0
+ }
+ return 1
+ }
+
+ usableRange := dev.maxBrightness - dev.minValue
+ if usableRange == 0 {
+ return 100
+ }
+
+ var percent int
+
+ if exponential {
+ hardwarePercent := float64(value-dev.minValue) / float64(usableRange)
+ normalizedPercent := math.Pow(hardwarePercent, 1.0/exponent)
+ percent = 1 + int(math.Round(normalizedPercent*99.0))
+ } else {
+ percent = 1 + int(math.Round(float64(value-dev.minValue)*99.0/float64(usableRange)))
+ }
+
+ if percent > 100 {
+ percent = 100
+ }
+ if percent < 1 {
+ percent = 1
+ }
+
+ return percent
+}
diff --git a/backend/internal/server/brightness/sysfs_logind_test.go b/backend/internal/server/brightness/sysfs_logind_test.go
new file mode 100644
index 00000000..999a3010
--- /dev/null
+++ b/backend/internal/server/brightness/sysfs_logind_test.go
@@ -0,0 +1,290 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ mocks_brightness "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/brightness"
+ mock_dbus "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestManager_SetBrightness_LogindSuccess(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ deviceCache: make(map[string]*sysfsDevice),
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ subscribers: make(map[string]chan State),
+ updateSubscribers: make(map[string]chan DeviceUpdate),
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", uint32(75)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() with logind error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ if string(data) == "75\n" {
+ t.Error("Direct sysfs write occurred when logind should have been used")
+ }
+}
+
+func TestManager_SetBrightness_LogindFailsFallbackToSysfs(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ deviceCache: make(map[string]*sysfsDevice),
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ subscribers: make(map[string]chan State),
+ updateSubscribers: make(map[string]chan DeviceUpdate),
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", mock.Anything).
+ Return(&dbus.Call{Err: dbus.ErrMsgNoObject}).
+ Once()
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() with fallback error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ brightness := string(data)
+ if brightness != "75" {
+ t.Errorf("Fallback sysfs write did not occur, got brightness = %q, want %q", brightness, "75")
+ }
+}
+
+func TestManager_SetBrightness_NoLogind(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight"},
+ deviceCache: make(map[string]*sysfsDevice),
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: nil,
+ sysfsBackend: sysfs,
+ logindReady: false,
+ sysfsReady: true,
+ subscribers: make(map[string]chan State),
+ updateSubscribers: make(map[string]chan DeviceUpdate),
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassBacklight,
+ ID: "backlight:test_backlight",
+ Name: "test_backlight",
+ Current: 50,
+ Max: 100,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ err := m.SetBrightness("backlight:test_backlight", 75)
+ if err != nil {
+ t.Errorf("SetBrightness() without logind error = %v, want nil", err)
+ }
+
+ data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness"))
+ brightness := string(data)
+ if brightness != "75" {
+ t.Errorf("Direct sysfs write = %q, want %q", brightness, "75")
+ }
+}
+
+func TestManager_SetBrightness_LEDWithLogind(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ ledsDir := filepath.Join(tmpDir, "leds", "test_led")
+ if err := os.MkdirAll(ledsDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ mockConn := mocks_brightness.NewMockDBusConn(t)
+ mockObj := mock_dbus.NewMockBusObject(t)
+
+ mockLogind := NewLogindBackendWithConn(mockConn)
+
+ sysfs := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"leds"},
+ deviceCache: make(map[string]*sysfsDevice),
+ }
+
+ if err := sysfs.scanDevices(); err != nil {
+ t.Fatal(err)
+ }
+
+ m := &Manager{
+ logindBackend: mockLogind,
+ sysfsBackend: sysfs,
+ logindReady: true,
+ sysfsReady: true,
+ subscribers: make(map[string]chan State),
+ updateSubscribers: make(map[string]chan DeviceUpdate),
+ stopChan: make(chan struct{}),
+ }
+
+ m.state = State{
+ Devices: []Device{
+ {
+ Class: ClassLED,
+ ID: "leds:test_led",
+ Name: "test_led",
+ Current: 128,
+ Max: 255,
+ CurrentPercent: 50,
+ Backend: "sysfs",
+ },
+ },
+ }
+
+ mockConn.EXPECT().
+ Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")).
+ Return(mockObj).
+ Once()
+
+ mockObj.EXPECT().
+ Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "leds", "test_led", uint32(0)).
+ Return(&dbus.Call{Err: nil}).
+ Once()
+
+ err := m.SetBrightness("leds:test_led", 0)
+ if err != nil {
+ t.Errorf("SetBrightness() LED with logind error = %v, want nil", err)
+ }
+}
diff --git a/backend/internal/server/brightness/sysfs_test.go b/backend/internal/server/brightness/sysfs_test.go
new file mode 100644
index 00000000..b31db63b
--- /dev/null
+++ b/backend/internal/server/brightness/sysfs_test.go
@@ -0,0 +1,185 @@
+package brightness
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSysfsBackend_PercentConversions(t *testing.T) {
+ tests := []struct {
+ name string
+ device *sysfsDevice
+ percent int
+ wantValue int
+ tolerance int
+ }{
+ {
+ name: "backlight 0% should be minValue=1",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 0,
+ wantValue: 1,
+ tolerance: 0,
+ },
+ {
+ name: "backlight 1% should be minValue=1",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 1,
+ wantValue: 1,
+ tolerance: 0,
+ },
+ {
+ name: "backlight 50% should be ~50",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 50,
+ wantValue: 50,
+ tolerance: 1,
+ },
+ {
+ name: "backlight 100% should be max",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ percent: 100,
+ wantValue: 100,
+ tolerance: 0,
+ },
+ {
+ name: "led 0% should be 0",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 0,
+ wantValue: 0,
+ tolerance: 0,
+ },
+ {
+ name: "led 1% should be ~2-3",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 1,
+ wantValue: 2,
+ tolerance: 3,
+ },
+ {
+ name: "led 50% should be ~127",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 50,
+ wantValue: 127,
+ tolerance: 2,
+ },
+ {
+ name: "led 100% should be max",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ percent: 100,
+ wantValue: 255,
+ tolerance: 0,
+ },
+ }
+
+ b := &SysfsBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.PercentToValue(tt.percent, tt.device, false)
+ diff := got - tt.wantValue
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > tt.tolerance {
+ t.Errorf("percentToValue() = %v, want %v (±%d)", got, tt.wantValue, tt.tolerance)
+ }
+
+ gotPercent := b.ValueToPercent(got, tt.device, false)
+ if tt.percent > 1 && gotPercent == 0 {
+ t.Errorf("valueToPercent() returned 0 for non-zero input (percent=%d, got value=%d)", tt.percent, got)
+ }
+ })
+ }
+}
+
+func TestSysfsBackend_ValueToPercent(t *testing.T) {
+ tests := []struct {
+ name string
+ device *sysfsDevice
+ value int
+ wantPercent int
+ }{
+ {
+ name: "backlight min value",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ value: 1,
+ wantPercent: 1,
+ },
+ {
+ name: "backlight max value",
+ device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight},
+ value: 100,
+ wantPercent: 100,
+ },
+ {
+ name: "led zero",
+ device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED},
+ value: 0,
+ wantPercent: 0,
+ },
+ }
+
+ b := &SysfsBackend{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := b.ValueToPercent(tt.value, tt.device, false)
+ if got != tt.wantPercent {
+ t.Errorf("valueToPercent() = %v, want %v", got, tt.wantPercent)
+ }
+ })
+ }
+}
+
+func TestSysfsBackend_ScanDevices(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
+ if err := os.MkdirAll(backlightDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ ledsDir := filepath.Join(tmpDir, "leds", "test_led")
+ if err := os.MkdirAll(ledsDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ b := &SysfsBackend{
+ basePath: tmpDir,
+ classes: []string{"backlight", "leds"},
+ deviceCache: make(map[string]*sysfsDevice),
+ }
+
+ if err := b.scanDevices(); err != nil {
+ t.Fatalf("scanDevices() error = %v", err)
+ }
+
+ if len(b.deviceCache) != 2 {
+ t.Errorf("expected 2 devices, got %d", len(b.deviceCache))
+ }
+
+ backlightID := "backlight:test_backlight"
+ if _, ok := b.deviceCache[backlightID]; !ok {
+ t.Errorf("backlight device not found")
+ }
+
+ ledID := "leds:test_led"
+ if _, ok := b.deviceCache[ledID]; !ok {
+ t.Errorf("LED device not found")
+ }
+}
diff --git a/backend/internal/server/brightness/types.go b/backend/internal/server/brightness/types.go
new file mode 100644
index 00000000..a1e83b93
--- /dev/null
+++ b/backend/internal/server/brightness/types.go
@@ -0,0 +1,199 @@
+package brightness
+
+import (
+ "sync"
+ "time"
+)
+
+type DeviceClass string
+
+const (
+ ClassBacklight DeviceClass = "backlight"
+ ClassLED DeviceClass = "leds"
+ ClassDDC DeviceClass = "ddc"
+)
+
+type Device struct {
+ Class DeviceClass `json:"class"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Current int `json:"current"`
+ Max int `json:"max"`
+ CurrentPercent int `json:"currentPercent"`
+ Backend string `json:"backend"`
+}
+
+type State struct {
+ Devices []Device `json:"devices"`
+}
+
+type DeviceUpdate struct {
+ Device Device `json:"device"`
+}
+
+type Request struct {
+ ID interface{} `json:"id"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params"`
+}
+
+type Manager struct {
+ logindBackend *LogindBackend
+ sysfsBackend *SysfsBackend
+ ddcBackend *DDCBackend
+
+ logindReady bool
+ sysfsReady bool
+ ddcReady bool
+
+ exponential bool
+
+ stateMutex sync.RWMutex
+ state State
+
+ subscribers map[string]chan State
+ updateSubscribers map[string]chan DeviceUpdate
+ subMutex sync.RWMutex
+
+ broadcastMutex sync.Mutex
+ broadcastTimer *time.Timer
+ broadcastPending bool
+ pendingDeviceID string
+
+ stopChan chan struct{}
+}
+
+type SysfsBackend struct {
+ basePath string
+ classes []string
+
+ deviceCache map[string]*sysfsDevice
+ deviceCacheMutex sync.RWMutex
+}
+
+type sysfsDevice struct {
+ class DeviceClass
+ id string
+ name string
+ maxBrightness int
+ minValue int
+}
+
+type DDCBackend struct {
+ devices map[string]*ddcDevice
+ devicesMutex sync.RWMutex
+
+ scanMutex sync.Mutex
+ lastScan time.Time
+ scanInterval time.Duration
+
+ debounceMutex sync.Mutex
+ debounceTimers map[string]*time.Timer
+ debouncePending map[string]ddcPendingSet
+}
+
+type ddcPendingSet struct {
+ percent int
+ callback func()
+}
+
+type ddcDevice struct {
+ bus int
+ addr int
+ id string
+ name string
+ max int
+ lastBrightness int
+}
+
+type ddcCapability struct {
+ vcp byte
+ max int
+ current int
+}
+
+type SetBrightnessParams struct {
+ Device string `json:"device"`
+ Percent int `json:"percent"`
+ Exponential bool `json:"exponential,omitempty"`
+ Exponent float64 `json:"exponent,omitempty"`
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 16)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) SubscribeUpdates(id string) chan DeviceUpdate {
+ ch := make(chan DeviceUpdate, 16)
+ m.subMutex.Lock()
+ m.updateSubscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) UnsubscribeUpdates(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.updateSubscribers[id]; ok {
+ close(ch)
+ delete(m.updateSubscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) NotifySubscribers() {
+ m.stateMutex.RLock()
+ state := m.state
+ m.stateMutex.RUnlock()
+
+ m.subMutex.RLock()
+ defer m.subMutex.RUnlock()
+
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- state:
+ default:
+ }
+ }
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan State)
+ for _, ch := range m.updateSubscribers {
+ close(ch)
+ }
+ m.updateSubscribers = make(map[string]chan DeviceUpdate)
+ m.subMutex.Unlock()
+
+ if m.logindBackend != nil {
+ m.logindBackend.Close()
+ }
+
+ if m.ddcBackend != nil {
+ m.ddcBackend.Close()
+ }
+}
diff --git a/backend/internal/server/cups/actions.go b/backend/internal/server/cups/actions.go
new file mode 100644
index 00000000..bb81bbae
--- /dev/null
+++ b/backend/internal/server/cups/actions.go
@@ -0,0 +1,107 @@
+package cups
+
+import (
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+)
+
+func (m *Manager) GetPrinters() ([]Printer, error) {
+ attributes := []string{
+ ipp.AttributePrinterName,
+ ipp.AttributePrinterUriSupported,
+ ipp.AttributePrinterState,
+ ipp.AttributePrinterStateReasons,
+ ipp.AttributePrinterLocation,
+ ipp.AttributePrinterInfo,
+ ipp.AttributePrinterMakeAndModel,
+ ipp.AttributePrinterIsAcceptingJobs,
+ }
+
+ printerAttrs, err := m.client.GetPrinters(attributes)
+ if err != nil {
+ return nil, err
+ }
+
+ printers := make([]Printer, 0, len(printerAttrs))
+ for _, attrs := range printerAttrs {
+ printer := Printer{
+ Name: getStringAttr(attrs, ipp.AttributePrinterName),
+ URI: getStringAttr(attrs, ipp.AttributePrinterUriSupported),
+ State: parsePrinterState(attrs),
+ StateReason: getStringAttr(attrs, ipp.AttributePrinterStateReasons),
+ Location: getStringAttr(attrs, ipp.AttributePrinterLocation),
+ Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
+ MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
+ Accepting: getBoolAttr(attrs, ipp.AttributePrinterIsAcceptingJobs),
+ }
+
+ if printer.Name != "" {
+ printers = append(printers, printer)
+ }
+ }
+
+ return printers, nil
+}
+
+func (m *Manager) GetJobs(printerName string, whichJobs string) ([]Job, error) {
+ attributes := []string{
+ ipp.AttributeJobID,
+ ipp.AttributeJobName,
+ ipp.AttributeJobState,
+ ipp.AttributeJobPrinterURI,
+ ipp.AttributeJobOriginatingUserName,
+ ipp.AttributeJobKilobyteOctets,
+ "time-at-creation",
+ }
+
+ jobAttrs, err := m.client.GetJobs(printerName, "", whichJobs, false, 0, 0, attributes)
+ if err != nil {
+ return nil, err
+ }
+
+ jobs := make([]Job, 0, len(jobAttrs))
+ for _, attrs := range jobAttrs {
+ job := Job{
+ ID: getIntAttr(attrs, ipp.AttributeJobID),
+ Name: getStringAttr(attrs, ipp.AttributeJobName),
+ State: parseJobState(attrs),
+ User: getStringAttr(attrs, ipp.AttributeJobOriginatingUserName),
+ Size: getIntAttr(attrs, ipp.AttributeJobKilobyteOctets) * 1024,
+ }
+
+ if uri := getStringAttr(attrs, ipp.AttributeJobPrinterURI); uri != "" {
+ parts := strings.Split(uri, "/")
+ if len(parts) > 0 {
+ job.Printer = parts[len(parts)-1]
+ }
+ }
+
+ if ts := getIntAttr(attrs, "time-at-creation"); ts > 0 {
+ job.TimeCreated = time.Unix(int64(ts), 0)
+ }
+
+ if job.ID != 0 {
+ jobs = append(jobs, job)
+ }
+ }
+
+ return jobs, nil
+}
+
+func (m *Manager) CancelJob(jobID int) error {
+ return m.client.CancelJob(jobID, false)
+}
+
+func (m *Manager) PausePrinter(printerName string) error {
+ return m.client.PausePrinter(printerName)
+}
+
+func (m *Manager) ResumePrinter(printerName string) error {
+ return m.client.ResumePrinter(printerName)
+}
+
+func (m *Manager) PurgeJobs(printerName string) error {
+ return m.client.CancelAllJob(printerName, true)
+}
diff --git a/backend/internal/server/cups/actions_test.go b/backend/internal/server/cups/actions_test.go
new file mode 100644
index 00000000..4962e603
--- /dev/null
+++ b/backend/internal/server/cups/actions_test.go
@@ -0,0 +1,285 @@
+package cups
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestManager_GetPrinters(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[string]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[string]ipp.Attributes{
+ "printer1": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}},
+ ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributePrinterStateReasons: []ipp.Attribute{{Value: "none"}},
+ ipp.AttributePrinterLocation: []ipp.Attribute{{Value: "Office"}},
+ ipp.AttributePrinterInfo: []ipp.Attribute{{Value: "Test Printer"}},
+ ipp.AttributePrinterMakeAndModel: []ipp.Attribute{{Value: "Generic"}},
+ ipp.AttributePrinterIsAcceptingJobs: []ipp.Attribute{{Value: true}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ got, err := m.GetPrinters()
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, "printer1", got[0].Name)
+ assert.Equal(t, "idle", got[0].State)
+ assert.Equal(t, "Office", got[0].Location)
+ assert.True(t, got[0].Accepting)
+ }
+ }
+ })
+ }
+}
+
+func TestManager_GetJobs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockRet map[int]ipp.Attributes
+ mockErr error
+ want int
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockRet: map[int]ipp.Attributes{
+ 1: {
+ ipp.AttributeJobID: []ipp.Attribute{{Value: 1}},
+ ipp.AttributeJobName: []ipp.Attribute{{Value: "test-job"}},
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ ipp.AttributeJobPrinterURI: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ ipp.AttributeJobOriginatingUserName: []ipp.Attribute{{Value: "testuser"}},
+ ipp.AttributeJobKilobyteOctets: []ipp.Attribute{{Value: 10}},
+ "time-at-creation": []ipp.Attribute{{Value: 1609459200}},
+ },
+ },
+ mockErr: nil,
+ want: 1,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockRet: nil,
+ mockErr: errors.New("test error"),
+ want: 0,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything).
+ Return(tt.mockRet, tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ got, err := m.GetJobs("printer1", "not-completed")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, len(got))
+ if len(got) > 0 {
+ assert.Equal(t, 1, got[0].ID)
+ assert.Equal(t, "test-job", got[0].Name)
+ assert.Equal(t, "processing", got[0].State)
+ assert.Equal(t, "testuser", got[0].User)
+ assert.Equal(t, "printer1", got[0].Printer)
+ assert.Equal(t, 10240, got[0].Size)
+ assert.Equal(t, time.Unix(1609459200, 0), got[0].TimeCreated)
+ }
+ }
+ })
+ }
+}
+
+func TestManager_CancelJob(t *testing.T) {
+ tests := []struct {
+ name string
+ mockErr error
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockErr: nil,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockErr: errors.New("test error"),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ err := m.CancelJob(1)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestManager_PausePrinter(t *testing.T) {
+ tests := []struct {
+ name string
+ mockErr error
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockErr: nil,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockErr: errors.New("test error"),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ err := m.PausePrinter("printer1")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestManager_ResumePrinter(t *testing.T) {
+ tests := []struct {
+ name string
+ mockErr error
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockErr: nil,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockErr: errors.New("test error"),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ err := m.ResumePrinter("printer1")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestManager_PurgeJobs(t *testing.T) {
+ tests := []struct {
+ name string
+ mockErr error
+ wantErr bool
+ }{
+ {
+ name: "success",
+ mockErr: nil,
+ wantErr: false,
+ },
+ {
+ name: "error",
+ mockErr: errors.New("test error"),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelAllJob("printer1", true).Return(tt.mockErr)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ err := m.PurgeJobs("printer1")
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/backend/internal/server/cups/handlers.go b/backend/internal/server/cups/handlers.go
new file mode 100644
index 00000000..6a08345c
--- /dev/null
+++ b/backend/internal/server/cups/handlers.go
@@ -0,0 +1,160 @@
+package cups
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+type CUPSEvent struct {
+ Type string `json:"type"`
+ Data CUPSState `json:"data"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ switch req.Method {
+ case "cups.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "cups.getPrinters":
+ handleGetPrinters(conn, req, manager)
+ case "cups.getJobs":
+ handleGetJobs(conn, req, manager)
+ case "cups.pausePrinter":
+ handlePausePrinter(conn, req, manager)
+ case "cups.resumePrinter":
+ handleResumePrinter(conn, req, manager)
+ case "cups.cancelJob":
+ handleCancelJob(conn, req, manager)
+ case "cups.purgeJobs":
+ handlePurgeJobs(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetPrinters(conn net.Conn, req Request, manager *Manager) {
+ printers, err := manager.GetPrinters()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, printers)
+}
+
+func handleGetJobs(conn net.Conn, req Request, manager *Manager) {
+ printerName, ok := req.Params["printerName"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'printerName' parameter")
+ return
+ }
+
+ jobs, err := manager.GetJobs(printerName, "not-completed")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, jobs)
+}
+
+func handlePausePrinter(conn net.Conn, req Request, manager *Manager) {
+ printerName, ok := req.Params["printerName"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'printerName' parameter")
+ return
+ }
+
+ if err := manager.PausePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "paused"})
+}
+
+func handleResumePrinter(conn net.Conn, req Request, manager *Manager) {
+ printerName, ok := req.Params["printerName"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'printerName' parameter")
+ return
+ }
+
+ if err := manager.ResumePrinter(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "resumed"})
+}
+
+func handleCancelJob(conn net.Conn, req Request, manager *Manager) {
+ jobIDFloat, ok := req.Params["jobID"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'jobid' parameter")
+ return
+ }
+ jobID := int(jobIDFloat)
+
+ if err := manager.CancelJob(jobID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "job canceled"})
+}
+
+func handlePurgeJobs(conn net.Conn, req Request, manager *Manager) {
+ printerName, ok := req.Params["printerName"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'printerName' parameter")
+ return
+ }
+
+ if err := manager.PurgeJobs(printerName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "jobs canceled"})
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := CUPSEvent{
+ Type: "state_changed",
+ Data: initialState,
+ }
+
+ if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := CUPSEvent{
+ Type: "state_changed",
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/backend/internal/server/cups/handlers_test.go b/backend/internal/server/cups/handlers_test.go
new file mode 100644
index 00000000..e6f67863
--- /dev/null
+++ b/backend/internal/server/cups/handlers_test.go
@@ -0,0 +1,279 @@
+package cups
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+type mockConn struct {
+ *bytes.Buffer
+}
+
+func (m *mockConn) Close() error { return nil }
+func (m *mockConn) LocalAddr() net.Addr { return nil }
+func (m *mockConn) RemoteAddr() net.Addr { return nil }
+func (m *mockConn) SetDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
+
+func TestHandleGetPrinters(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{
+ "printer1": {
+ ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}},
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}},
+ },
+ }, nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.getPrinters",
+ }
+
+ handleGetPrinters(conn, req, m)
+
+ var resp models.Response[[]Printer]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetPrinters_Error(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetPrinters(mock.Anything).Return(nil, errors.New("test error"))
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.getPrinters",
+ }
+
+ handleGetPrinters(conn, req, m)
+
+ var resp models.Response[interface{}]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
+
+func TestHandleGetJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything).
+ Return(map[int]ipp.Attributes{
+ 1: {
+ ipp.AttributeJobID: []ipp.Attribute{{Value: 1}},
+ ipp.AttributeJobName: []ipp.Attribute{{Value: "job1"}},
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ },
+ }, nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.getJobs",
+ Params: map[string]interface{}{
+ "printerName": "printer1",
+ },
+ }
+
+ handleGetJobs(conn, req, m)
+
+ var resp models.Response[[]Job]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.Equal(t, 1, len(*resp.Result))
+}
+
+func TestHandleGetJobs_MissingParam(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.getJobs",
+ Params: map[string]interface{}{},
+ }
+
+ handleGetJobs(conn, req, m)
+
+ var resp models.Response[interface{}]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
+
+func TestHandlePausePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().PausePrinter("printer1").Return(nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.pausePrinter",
+ Params: map[string]interface{}{
+ "printerName": "printer1",
+ },
+ }
+
+ handlePausePrinter(conn, req, m)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleResumePrinter(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().ResumePrinter("printer1").Return(nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.resumePrinter",
+ Params: map[string]interface{}{
+ "printerName": "printer1",
+ },
+ }
+
+ handleResumePrinter(conn, req, m)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleCancelJob(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelJob(1, false).Return(nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.cancelJob",
+ Params: map[string]interface{}{
+ "jobID": float64(1),
+ },
+ }
+
+ handleCancelJob(conn, req, m)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandlePurgeJobs(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+ mockClient.EXPECT().CancelAllJob("printer1", true).Return(nil)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.purgeJobs",
+ Params: map[string]interface{}{
+ "printerName": "printer1",
+ },
+ }
+
+ handlePurgeJobs(conn, req, m)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+}
+
+func TestHandleRequest_UnknownMethod(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ client: mockClient,
+ }
+
+ buf := &bytes.Buffer{}
+ conn := &mockConn{Buffer: buf}
+
+ req := Request{
+ ID: 1,
+ Method: "cups.unknownMethod",
+ }
+
+ HandleRequest(conn, req, m)
+
+ var resp models.Response[interface{}]
+ err := json.NewDecoder(buf).Decode(&resp)
+ assert.NoError(t, err)
+ assert.Nil(t, resp.Result)
+ assert.NotNil(t, resp.Error)
+}
diff --git a/backend/internal/server/cups/manager.go b/backend/internal/server/cups/manager.go
new file mode 100644
index 00000000..2df6d51a
--- /dev/null
+++ b/backend/internal/server/cups/manager.go
@@ -0,0 +1,340 @@
+package cups
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+)
+
+func NewManager() (*Manager, error) {
+ host := os.Getenv("DMS_IPP_HOST")
+ if host == "" {
+ host = "localhost"
+ }
+
+ portStr := os.Getenv("DMS_IPP_PORT")
+ port := 631
+ if portStr != "" {
+ if p, err := strconv.Atoi(portStr); err == nil {
+ port = p
+ }
+ }
+
+ username := os.Getenv("DMS_IPP_USERNAME")
+ password := os.Getenv("DMS_IPP_PASSWORD")
+
+ client := ipp.NewCUPSClient(host, port, username, password, false)
+ baseURL := fmt.Sprintf("http://%s:%d", host, port)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: client,
+ baseURL: baseURL,
+ stateMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ subscribers: make(map[string]chan CUPSState),
+ subMutex: sync.RWMutex{},
+ }
+
+ if err := m.updateState(); err != nil {
+ return nil, err
+ }
+
+ if isLocalCUPS(host) {
+ m.subscription = NewDBusSubscriptionManager(client, baseURL)
+ log.Infof("[CUPS] Using D-Bus notifications for local CUPS")
+ } else {
+ m.subscription = NewSubscriptionManager(client, baseURL)
+ log.Infof("[CUPS] Using IPPGET notifications for remote CUPS")
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func isLocalCUPS(host string) bool {
+ switch host {
+ case "localhost", "127.0.0.1", "::1", "":
+ return true
+ }
+ return false
+}
+
+func (m *Manager) eventHandler() {
+ defer m.eventWG.Done()
+
+ if m.subscription == nil {
+ return
+ }
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case event, ok := <-m.subscription.Events():
+ if !ok {
+ return
+ }
+ log.Debugf("[CUPS] Received event: %s (printer: %s, job: %d)",
+ event.EventName, event.PrinterName, event.JobID)
+
+ if err := m.updateState(); err != nil {
+ log.Warnf("[CUPS] Failed to update state after event: %v", err)
+ } else {
+ m.notifySubscribers()
+ }
+ }
+ }
+}
+
+func (m *Manager) updateState() error {
+ printers, err := m.GetPrinters()
+ if err != nil {
+ return err
+ }
+
+ printerMap := make(map[string]*Printer, len(printers))
+ for _, printer := range printers {
+ jobs, err := m.GetJobs(printer.Name, "not-completed")
+ if err != nil {
+ return err
+ }
+
+ printer.Jobs = jobs
+ printerMap[printer.Name] = &printer
+ }
+
+ m.stateMutex.Lock()
+ m.state.Printers = printerMap
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.subMutex.RLock()
+ if len(m.subscribers) == 0 {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, ¤tState) {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ }
+ m.subMutex.RUnlock()
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) GetState() CUPSState {
+ return m.snapshotState()
+}
+
+func (m *Manager) snapshotState() CUPSState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ s := CUPSState{
+ Printers: make(map[string]*Printer, len(m.state.Printers)),
+ }
+ for name, printer := range m.state.Printers {
+ printerCopy := *printer
+ s.Printers[name] = &printerCopy
+ }
+ return s
+}
+
+func (m *Manager) Subscribe(id string) chan CUPSState {
+ ch := make(chan CUPSState, 64)
+ m.subMutex.Lock()
+ wasEmpty := len(m.subscribers) == 0
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+
+ if wasEmpty && m.subscription != nil {
+ if err := m.subscription.Start(); err != nil {
+ log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
+ } else {
+ m.eventWG.Add(1)
+ go m.eventHandler()
+ }
+ }
+
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ isEmpty := len(m.subscribers) == 0
+ m.subMutex.Unlock()
+
+ if isEmpty && m.subscription != nil {
+ m.subscription.Stop()
+ m.eventWG.Wait()
+ }
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+
+ if m.subscription != nil {
+ m.subscription.Stop()
+ }
+
+ m.eventWG.Wait()
+ m.notifierWg.Wait()
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan CUPSState)
+ m.subMutex.Unlock()
+}
+
+func stateChanged(old, new *CUPSState) bool {
+ if len(old.Printers) != len(new.Printers) {
+ return true
+ }
+ for name, oldPrinter := range old.Printers {
+ newPrinter, exists := new.Printers[name]
+ if !exists {
+ return true
+ }
+ if oldPrinter.State != newPrinter.State ||
+ oldPrinter.StateReason != newPrinter.StateReason ||
+ len(oldPrinter.Jobs) != len(newPrinter.Jobs) {
+ return true
+ }
+ }
+ return false
+}
+
+func parsePrinterState(attrs ipp.Attributes) string {
+ if stateAttr, ok := attrs[ipp.AttributePrinterState]; ok && len(stateAttr) > 0 {
+ if state, ok := stateAttr[0].Value.(int); ok {
+ switch state {
+ case 3:
+ return "idle"
+ case 4:
+ return "processing"
+ case 5:
+ return "stopped"
+ default:
+ return fmt.Sprintf("%d", state)
+ }
+ }
+ }
+ return "unknown"
+}
+
+func parseJobState(attrs ipp.Attributes) string {
+ if stateAttr, ok := attrs[ipp.AttributeJobState]; ok && len(stateAttr) > 0 {
+ if state, ok := stateAttr[0].Value.(int); ok {
+ switch state {
+ case 3:
+ return "pending"
+ case 4:
+ return "pending-held"
+ case 5:
+ return "processing"
+ case 6:
+ return "processing-stopped"
+ case 7:
+ return "canceled"
+ case 8:
+ return "aborted"
+ case 9:
+ return "completed"
+ default:
+ return fmt.Sprintf("%d", state)
+ }
+ }
+ }
+ return "unknown"
+}
+
+func getStringAttr(attrs ipp.Attributes, key string) string {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ return val
+ }
+ return fmt.Sprintf("%v", attr[0].Value)
+ }
+ return ""
+}
+
+func getIntAttr(attrs ipp.Attributes, key string) int {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(int); ok {
+ return val
+ }
+ }
+ return 0
+}
+
+func getBoolAttr(attrs ipp.Attributes, key string) bool {
+ if attr, ok := attrs[key]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(bool); ok {
+ return val
+ }
+ }
+ return false
+}
diff --git a/backend/internal/server/cups/manager_test.go b/backend/internal/server/cups/manager_test.go
new file mode 100644
index 00000000..31aa0a1f
--- /dev/null
+++ b/backend/internal/server/cups/manager_test.go
@@ -0,0 +1,351 @@
+package cups
+
+import (
+ "testing"
+
+ mocks_cups "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/cups"
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewManager(t *testing.T) {
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: nil,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ subscribers: make(map[string]chan CUPSState),
+ }
+
+ assert.NotNil(t, m)
+ assert.NotNil(t, m.state)
+}
+
+func TestManager_GetState(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: map[string]*Printer{
+ "test-printer": {
+ Name: "test-printer",
+ State: "idle",
+ },
+ },
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ subscribers: make(map[string]chan CUPSState),
+ }
+
+ state := m.GetState()
+ assert.Equal(t, 1, len(state.Printers))
+ assert.Equal(t, "test-printer", state.Printers["test-printer"].Name)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ subscribers: make(map[string]chan CUPSState),
+ }
+
+ ch := m.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ assert.Equal(t, 1, len(m.subscribers))
+
+ m.Unsubscribe("test-client")
+ assert.Equal(t, 0, len(m.subscribers))
+}
+
+func TestManager_Close(t *testing.T) {
+ mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+
+ m := &Manager{
+ state: &CUPSState{
+ Printers: make(map[string]*Printer),
+ },
+ client: mockClient,
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ subscribers: make(map[string]chan CUPSState),
+ }
+
+ m.eventWG.Add(1)
+ go func() {
+ defer m.eventWG.Done()
+ <-m.stopChan
+ }()
+
+ m.notifierWg.Add(1)
+ go func() {
+ defer m.notifierWg.Done()
+ <-m.stopChan
+ }()
+
+ m.Close()
+ assert.Equal(t, 0, len(m.subscribers))
+}
+
+func TestStateChanged(t *testing.T) {
+ tests := []struct {
+ name string
+ oldState *CUPSState
+ newState *CUPSState
+ want bool
+ }{
+ {
+ name: "no change",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "state changed",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "processing"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "printer added",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{},
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "printer removed",
+ oldState: &CUPSState{
+ Printers: map[string]*Printer{
+ "p1": {Name: "p1", State: "idle"},
+ },
+ },
+ newState: &CUPSState{
+ Printers: map[string]*Printer{},
+ },
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stateChanged(tt.oldState, tt.newState)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestParsePrinterState(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ want string
+ }{
+ {
+ name: "idle",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}},
+ },
+ want: "idle",
+ },
+ {
+ name: "processing",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 4}},
+ },
+ want: "processing",
+ },
+ {
+ name: "stopped",
+ attrs: ipp.Attributes{
+ ipp.AttributePrinterState: []ipp.Attribute{{Value: 5}},
+ },
+ want: "stopped",
+ },
+ {
+ name: "unknown",
+ attrs: ipp.Attributes{},
+ want: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parsePrinterState(tt.attrs)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestParseJobState(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ want string
+ }{
+ {
+ name: "pending",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 3}},
+ },
+ want: "pending",
+ },
+ {
+ name: "processing",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 5}},
+ },
+ want: "processing",
+ },
+ {
+ name: "completed",
+ attrs: ipp.Attributes{
+ ipp.AttributeJobState: []ipp.Attribute{{Value: 9}},
+ },
+ want: "completed",
+ },
+ {
+ name: "unknown",
+ attrs: ipp.Attributes{},
+ want: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseJobState(tt.attrs)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetStringAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want string
+ }{
+ {
+ name: "string value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: "test-value"}},
+ },
+ key: "test-key",
+ want: "test-value",
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getStringAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetIntAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want int
+ }{
+ {
+ name: "int value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: 42}},
+ },
+ key: "test-key",
+ want: 42,
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getIntAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestGetBoolAttr(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs ipp.Attributes
+ key string
+ want bool
+ }{
+ {
+ name: "true value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: true}},
+ },
+ key: "test-key",
+ want: true,
+ },
+ {
+ name: "false value",
+ attrs: ipp.Attributes{
+ "test-key": []ipp.Attribute{{Value: false}},
+ },
+ key: "test-key",
+ want: false,
+ },
+ {
+ name: "missing key",
+ attrs: ipp.Attributes{},
+ key: "missing",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getBoolAttr(tt.attrs, tt.key)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/backend/internal/server/cups/subscription.go b/backend/internal/server/cups/subscription.go
new file mode 100644
index 00000000..7f267d71
--- /dev/null
+++ b/backend/internal/server/cups/subscription.go
@@ -0,0 +1,245 @@
+package cups
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+)
+
+type SubscriptionManager struct {
+ client CUPSClientInterface
+ subscriptionID int
+ sequenceNumber int
+ eventChan chan SubscriptionEvent
+ stopChan chan struct{}
+ wg sync.WaitGroup
+ baseURL string
+ running bool
+ mu sync.Mutex
+}
+
+func NewSubscriptionManager(client CUPSClientInterface, baseURL string) *SubscriptionManager {
+ return &SubscriptionManager{
+ client: client,
+ eventChan: make(chan SubscriptionEvent, 100),
+ stopChan: make(chan struct{}),
+ baseURL: baseURL,
+ }
+}
+
+func (sm *SubscriptionManager) Start() error {
+ sm.mu.Lock()
+ if sm.running {
+ sm.mu.Unlock()
+ return fmt.Errorf("subscription manager already running")
+ }
+ sm.running = true
+ sm.mu.Unlock()
+
+ subID, err := sm.createSubscription()
+ if err != nil {
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to create subscription: %w", err)
+ }
+
+ sm.subscriptionID = subID
+ log.Infof("[CUPS] Created IPP subscription with ID %d", subID)
+
+ sm.wg.Add(1)
+ go sm.notificationLoop()
+
+ return nil
+}
+
+func (sm *SubscriptionManager) createSubscription() (int, error) {
+ req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+
+ // Subscription attributes go in SubscriptionAttributes (subscription-attributes-tag in IPP)
+ req.SubscriptionAttributes = map[string]interface{}{
+ "notify-events": []string{
+ "printer-state-changed",
+ "printer-added",
+ "printer-deleted",
+ "job-created",
+ "job-completed",
+ "job-state-changed",
+ },
+ "notify-pull-method": "ippget",
+ "notify-lease-duration": 0,
+ }
+
+ // Send to root IPP endpoint
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return 0, fmt.Errorf("SendRequest failed: %w", err)
+ }
+
+ // Check for IPP errors
+ if err := resp.CheckForErrors(); err != nil {
+ return 0, fmt.Errorf("IPP error: %w", err)
+ }
+
+ // Subscription ID comes back in SubscriptionAttributes
+ if len(resp.SubscriptionAttributes) > 0 {
+ if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 {
+ if val, ok := idAttr[0].Value.(int); ok {
+ return val, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("no subscription ID returned")
+}
+
+func (sm *SubscriptionManager) notificationLoop() {
+ defer sm.wg.Done()
+
+ backoff := 1 * time.Second
+
+ for {
+ select {
+ case <-sm.stopChan:
+ return
+ default:
+ }
+
+ gotAny, err := sm.fetchNotificationsWithWait()
+ if err != nil {
+ log.Warnf("[CUPS] Error fetching notifications: %v", err)
+ jitter := time.Duration(50+(time.Now().UnixNano()%200)) * time.Millisecond
+ sleepTime := backoff + jitter
+ if sleepTime > 30*time.Second {
+ sleepTime = 30 * time.Second
+ }
+ select {
+ case <-sm.stopChan:
+ return
+ case <-time.After(sleepTime):
+ }
+ if backoff < 30*time.Second {
+ backoff *= 2
+ }
+ continue
+ }
+
+ backoff = 1 * time.Second
+
+ if gotAny {
+ continue
+ }
+
+ select {
+ case <-sm.stopChan:
+ return
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+func (sm *SubscriptionManager) fetchNotificationsWithWait() (bool, error) {
+ req := ipp.NewRequest(ipp.OperationGetNotifications, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-ids"] = sm.subscriptionID
+ if sm.sequenceNumber > 0 {
+ req.OperationAttributes["notify-sequence-numbers"] = sm.sequenceNumber
+ }
+
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return false, err
+ }
+
+ gotAny := false
+ for _, eventGroup := range resp.SubscriptionAttributes {
+ if seqAttr, ok := eventGroup["notify-sequence-number"]; ok && len(seqAttr) > 0 {
+ if seqNum, ok := seqAttr[0].Value.(int); ok {
+ sm.sequenceNumber = seqNum + 1
+ }
+ }
+
+ event := sm.parseEvent(eventGroup)
+ gotAny = true
+ select {
+ case sm.eventChan <- event:
+ case <-sm.stopChan:
+ return gotAny, nil
+ default:
+ log.Warn("[CUPS] Event channel full, dropping event")
+ }
+ }
+
+ return gotAny, nil
+}
+
+func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEvent {
+ event := SubscriptionEvent{
+ SubscribedAt: time.Now(),
+ }
+
+ if attr, ok := attrs["notify-subscribed-event"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ event.EventName = val
+ }
+ }
+
+ if attr, ok := attrs["printer-name"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(string); ok {
+ event.PrinterName = val
+ }
+ }
+
+ if attr, ok := attrs["notify-job-id"]; ok && len(attr) > 0 {
+ if val, ok := attr[0].Value.(int); ok {
+ event.JobID = val
+ }
+ }
+
+ return event
+}
+
+func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
+ return sm.eventChan
+}
+
+func (sm *SubscriptionManager) Stop() {
+ sm.mu.Lock()
+ if !sm.running {
+ sm.mu.Unlock()
+ return
+ }
+ sm.running = false
+ sm.mu.Unlock()
+
+ close(sm.stopChan)
+ sm.wg.Wait()
+
+ if sm.subscriptionID != 0 {
+ sm.cancelSubscription()
+ sm.subscriptionID = 0
+ sm.sequenceNumber = 0
+ }
+
+ sm.stopChan = make(chan struct{})
+}
+
+func (sm *SubscriptionManager) cancelSubscription() {
+ req := ipp.NewRequest(ipp.OperationCancelSubscription, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID
+
+ _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err)
+ } else {
+ log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID)
+ }
+}
diff --git a/backend/internal/server/cups/subscription_dbus.go b/backend/internal/server/cups/subscription_dbus.go
new file mode 100644
index 00000000..dccb9247
--- /dev/null
+++ b/backend/internal/server/cups/subscription_dbus.go
@@ -0,0 +1,295 @@
+package cups
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+ "github.com/godbus/dbus/v5"
+)
+
+type DBusSubscriptionManager struct {
+ client CUPSClientInterface
+ subscriptionID int
+ eventChan chan SubscriptionEvent
+ stopChan chan struct{}
+ wg sync.WaitGroup
+ baseURL string
+ running bool
+ mu sync.Mutex
+ conn *dbus.Conn
+}
+
+func NewDBusSubscriptionManager(client CUPSClientInterface, baseURL string) *DBusSubscriptionManager {
+ return &DBusSubscriptionManager{
+ client: client,
+ eventChan: make(chan SubscriptionEvent, 100),
+ stopChan: make(chan struct{}),
+ baseURL: baseURL,
+ }
+}
+
+func (sm *DBusSubscriptionManager) Start() error {
+ sm.mu.Lock()
+ if sm.running {
+ sm.mu.Unlock()
+ return fmt.Errorf("subscription manager already running")
+ }
+ sm.running = true
+ sm.mu.Unlock()
+
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("connect to system bus: %w", err)
+ }
+ sm.conn = conn
+
+ subID, err := sm.createDBusSubscription()
+ if err != nil {
+ sm.conn.Close()
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to create D-Bus subscription: %w", err)
+ }
+
+ sm.subscriptionID = subID
+ log.Infof("[CUPS] Created D-Bus subscription with ID %d", subID)
+
+ if err := sm.conn.AddMatchSignal(
+ dbus.WithMatchInterface("org.cups.cupsd.Notifier"),
+ ); err != nil {
+ sm.cancelSubscription()
+ sm.conn.Close()
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return fmt.Errorf("failed to add D-Bus match: %w", err)
+ }
+
+ sm.wg.Add(1)
+ go sm.dbusListenerLoop()
+
+ return nil
+}
+
+func (sm *DBusSubscriptionManager) createDBusSubscription() (int, error) {
+ req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 2)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+
+ req.SubscriptionAttributes = map[string]interface{}{
+ "notify-events": []string{
+ "printer-state-changed",
+ "printer-added",
+ "printer-deleted",
+ "job-created",
+ "job-completed",
+ "job-state-changed",
+ },
+ "notify-recipient-uri": "dbus:/",
+ "notify-lease-duration": 86400,
+ }
+
+ resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ return 0, fmt.Errorf("SendRequest failed: %w", err)
+ }
+
+ if err := resp.CheckForErrors(); err != nil {
+ return 0, fmt.Errorf("IPP error: %w", err)
+ }
+
+ if len(resp.SubscriptionAttributes) > 0 {
+ if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 {
+ if val, ok := idAttr[0].Value.(int); ok {
+ return val, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("no subscription ID returned")
+}
+
+func (sm *DBusSubscriptionManager) dbusListenerLoop() {
+ defer sm.wg.Done()
+
+ signalChan := make(chan *dbus.Signal, 10)
+ sm.conn.Signal(signalChan)
+ defer sm.conn.RemoveSignal(signalChan)
+
+ for {
+ select {
+ case <-sm.stopChan:
+ return
+ case sig := <-signalChan:
+ if sig == nil {
+ continue
+ }
+
+ event := sm.parseDBusSignal(sig)
+ if event.EventName == "" {
+ continue
+ }
+
+ select {
+ case sm.eventChan <- event:
+ case <-sm.stopChan:
+ return
+ default:
+ log.Warn("[CUPS] Event channel full, dropping event")
+ }
+ }
+ }
+}
+
+func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) SubscriptionEvent {
+ event := SubscriptionEvent{}
+
+ switch sig.Name {
+ case "org.cups.cupsd.Notifier.JobStateChanged":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-state-changed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.JobCreated":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-created"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.JobCompleted":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "job-completed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ if jobID, ok := sig.Body[3].(uint32); ok {
+ event.JobID = int(jobID)
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterStateChanged":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-state-changed"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" {
+ if idx := strings.LastIndex(printerURI, "/"); idx != -1 {
+ event.PrinterName = printerURI[idx+1:]
+ }
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterAdded":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-added"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ }
+
+ case "org.cups.cupsd.Notifier.PrinterDeleted":
+ if len(sig.Body) >= 6 {
+ if text, ok := sig.Body[0].(string); ok {
+ event.EventName = "printer-deleted"
+ parts := strings.Split(text, " ")
+ if len(parts) >= 2 {
+ event.PrinterName = parts[0]
+ }
+ }
+ }
+ }
+
+ return event
+}
+
+func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
+ return sm.eventChan
+}
+
+func (sm *DBusSubscriptionManager) Stop() {
+ sm.mu.Lock()
+ if !sm.running {
+ sm.mu.Unlock()
+ return
+ }
+ sm.running = false
+ sm.mu.Unlock()
+
+ close(sm.stopChan)
+ sm.wg.Wait()
+
+ if sm.subscriptionID != 0 {
+ sm.cancelSubscription()
+ sm.subscriptionID = 0
+ }
+
+ if sm.conn != nil {
+ sm.conn.Close()
+ sm.conn = nil
+ }
+
+ sm.stopChan = make(chan struct{})
+}
+
+func (sm *DBusSubscriptionManager) cancelSubscription() {
+ req := ipp.NewRequest(ipp.OperationCancelSubscription, 1)
+ req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL)
+ req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms"
+ req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID
+
+ _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil)
+ if err != nil {
+ log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err)
+ } else {
+ log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID)
+ }
+}
diff --git a/backend/internal/server/cups/types.go b/backend/internal/server/cups/types.go
new file mode 100644
index 00000000..ae5deb71
--- /dev/null
+++ b/backend/internal/server/cups/types.go
@@ -0,0 +1,73 @@
+package cups
+
+import (
+ "io"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/pkg/ipp"
+)
+
+type CUPSState struct {
+ Printers map[string]*Printer `json:"printers"`
+}
+
+type Printer struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ State string `json:"state"`
+ StateReason string `json:"stateReason"`
+ Location string `json:"location"`
+ Info string `json:"info"`
+ MakeModel string `json:"makeModel"`
+ Accepting bool `json:"accepting"`
+ Jobs []Job `json:"jobs"`
+}
+
+type Job struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ State string `json:"state"`
+ Printer string `json:"printer"`
+ User string `json:"user"`
+ Size int `json:"size"`
+ TimeCreated time.Time `json:"timeCreated"`
+}
+
+type Manager struct {
+ state *CUPSState
+ client CUPSClientInterface
+ subscription SubscriptionManagerInterface
+ stateMutex sync.RWMutex
+ subscribers map[string]chan CUPSState
+ subMutex sync.RWMutex
+ stopChan chan struct{}
+ eventWG sync.WaitGroup
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *CUPSState
+ baseURL string
+}
+
+type SubscriptionManagerInterface interface {
+ Start() error
+ Stop()
+ Events() <-chan SubscriptionEvent
+}
+
+type CUPSClientInterface interface {
+ GetPrinters(attributes []string) (map[string]ipp.Attributes, error)
+ GetJobs(printer, class string, whichJobs string, myJobs bool, firstJobId, limit int, attributes []string) (map[int]ipp.Attributes, error)
+ CancelJob(jobID int, purge bool) error
+ PausePrinter(printer string) error
+ ResumePrinter(printer string) error
+ CancelAllJob(printer string, purge bool) error
+ SendRequest(url string, req *ipp.Request, additionalResponseData io.Writer) (*ipp.Response, error)
+}
+
+type SubscriptionEvent struct {
+ EventName string
+ PrinterName string
+ JobID int
+ SubscribedAt time.Time
+}
diff --git a/backend/internal/server/dwl/handlers.go b/backend/internal/server/dwl/handlers.go
new file mode 100644
index 00000000..bdb51d35
--- /dev/null
+++ b/backend/internal/server/dwl/handlers.go
@@ -0,0 +1,144 @@
+package dwl
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "dwl manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "dwl.getState":
+ handleGetState(conn, req, manager)
+ case "dwl.setTags":
+ handleSetTags(conn, req, manager)
+ case "dwl.setClientTags":
+ handleSetClientTags(conn, req, manager)
+ case "dwl.setLayout":
+ handleSetLayout(conn, req, manager)
+ case "dwl.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleSetTags(conn net.Conn, req Request, manager *Manager) {
+ output, ok := req.Params["output"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
+ return
+ }
+
+ tagmask, ok := req.Params["tagmask"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter")
+ return
+ }
+
+ toggleTagset, ok := req.Params["toggleTagset"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter")
+ return
+ }
+
+ if err := manager.SetTags(output, uint32(tagmask), uint32(toggleTagset)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "tags set"})
+}
+
+func handleSetClientTags(conn net.Conn, req Request, manager *Manager) {
+ output, ok := req.Params["output"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
+ return
+ }
+
+ andTags, ok := req.Params["andTags"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter")
+ return
+ }
+
+ xorTags, ok := req.Params["xorTags"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter")
+ return
+ }
+
+ if err := manager.SetClientTags(output, uint32(andTags), uint32(xorTags)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "client tags set"})
+}
+
+func handleSetLayout(conn net.Conn, req Request, manager *Manager) {
+ output, ok := req.Params["output"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'output' parameter")
+ return
+ }
+
+ index, ok := req.Params["index"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'index' parameter")
+ return
+ }
+
+ if err := manager.SetLayout(output, uint32(index)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "layout set"})
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/backend/internal/server/dwl/manager.go b/backend/internal/server/dwl/manager.go
new file mode 100644
index 00000000..ed5d6b4f
--- /dev/null
+++ b/backend/internal/server/dwl/manager.go
@@ -0,0 +1,539 @@
+package dwl
+
+import (
+ "fmt"
+ "time"
+
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/proto/dwl_ipc"
+)
+
+func NewManager(display *wlclient.Display) (*Manager, error) {
+ m := &Manager{
+ display: display,
+ outputs: make(map[uint32]*outputState),
+ cmdq: make(chan cmd, 128),
+ outputSetupReq: make(chan uint32, 16),
+ stopChan: make(chan struct{}),
+ subscribers: make(map[string]chan State),
+ dirty: make(chan struct{}, 1),
+ layouts: make([]string, 0),
+ }
+
+ if err := m.setupRegistry(); err != nil {
+ return nil, err
+ }
+
+ m.updateState()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("DWL actor command queue full, dropping command")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ c.fn()
+ case outputID := <-m.outputSetupReq:
+ m.outputsMutex.RLock()
+ out, exists := m.outputs[outputID]
+ m.outputsMutex.RUnlock()
+
+ if !exists {
+ log.Warnf("DWL: Output %d no longer exists, skipping setup", outputID)
+ continue
+ }
+
+ if out.ipcOutput != nil {
+ continue
+ }
+
+ mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2)
+ if !ok || mgr == nil {
+ log.Errorf("DWL: Manager not available for output %d setup", outputID)
+ continue
+ }
+
+ log.Infof("DWL: Setting up ipcOutput for dynamically added output %d", outputID)
+ if err := m.setupOutput(mgr, out.output); err != nil {
+ log.Errorf("DWL: Failed to setup output %d: %v", outputID, err)
+ } else {
+ m.updateState()
+ }
+ }
+ }
+}
+
+func (m *Manager) setupRegistry() error {
+ log.Info("DWL: starting registry setup")
+ ctx := m.display.Context()
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ outputs := make([]*wlclient.Output, 0)
+ outputRegNames := make(map[uint32]uint32)
+ var dwlMgr *dwl_ipc.ZdwlIpcManagerV2
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ switch e.Interface {
+ case dwl_ipc.ZdwlIpcManagerV2InterfaceName:
+ log.Infof("DWL: found %s", dwl_ipc.ZdwlIpcManagerV2InterfaceName)
+ manager := dwl_ipc.NewZdwlIpcManagerV2(ctx)
+ version := e.Version
+ if version > 1 {
+ version = 1
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ dwlMgr = manager
+ log.Info("DWL: manager bound successfully")
+ } else {
+ log.Errorf("DWL: failed to bind manager: %v", err)
+ }
+ case "wl_output":
+ log.Debugf("DWL: found wl_output (name=%d)", e.Name)
+ output := wlclient.NewOutput(ctx)
+
+ outState := &outputState{
+ registryName: e.Name,
+ output: output,
+ tags: make([]TagState, 0),
+ }
+
+ output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
+ log.Debugf("DWL: Output name: %s (registry=%d)", ev.Name, e.Name)
+ outState.name = ev.Name
+ })
+
+ output.SetDescriptionHandler(func(ev wlclient.OutputDescriptionEvent) {
+ log.Debugf("DWL: Output description: %s", ev.Description)
+ })
+
+ version := e.Version
+ if version > 4 {
+ version = 4
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
+ outputID := output.ID()
+ outState.id = outputID
+ log.Infof("DWL: Bound wl_output id=%d registry_name=%d", outputID, e.Name)
+ outputs = append(outputs, output)
+ outputRegNames[outputID] = e.Name
+
+ m.outputsMutex.Lock()
+ m.outputs[outputID] = outState
+ m.outputsMutex.Unlock()
+
+ if m.manager != nil {
+ select {
+ case m.outputSetupReq <- outputID:
+ log.Debugf("DWL: Queued setup for output %d", outputID)
+ default:
+ log.Warnf("DWL: Setup queue full, output %d will not be initialized", outputID)
+ }
+ }
+ } else {
+ log.Errorf("DWL: Failed to bind wl_output: %v", err)
+ }
+ }
+ })
+
+ registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
+ m.post(func() {
+ m.outputsMutex.Lock()
+ var outToRelease *outputState
+ for id, out := range m.outputs {
+ if out.registryName == e.Name {
+ log.Infof("DWL: Output %d removed", id)
+ outToRelease = out
+ delete(m.outputs, id)
+ break
+ }
+ }
+ m.outputsMutex.Unlock()
+
+ if outToRelease != nil {
+ if ipcOut, ok := outToRelease.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok && ipcOut != nil {
+ m.wlMutex.Lock()
+ ipcOut.Release()
+ m.wlMutex.Unlock()
+ log.Debugf("DWL: Released ipcOutput for removed output %d", outToRelease.id)
+ }
+ m.updateState()
+ }
+ })
+ })
+
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("first roundtrip failed: %w", err)
+ }
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("second roundtrip failed: %w", err)
+ }
+
+ if dwlMgr == nil {
+ log.Info("DWL: manager not found in registry")
+ return fmt.Errorf("dwl_ipc_manager_v2 not available")
+ }
+
+ dwlMgr.SetTagsHandler(func(e dwl_ipc.ZdwlIpcManagerV2TagsEvent) {
+ log.Infof("DWL: Tags count: %d", e.Amount)
+ m.tagCount = e.Amount
+ m.updateState()
+ })
+
+ dwlMgr.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcManagerV2LayoutEvent) {
+ log.Infof("DWL: Layout: %s", e.Name)
+ m.layouts = append(m.layouts, e.Name)
+ m.updateState()
+ })
+
+ m.manager = dwlMgr
+
+ for _, output := range outputs {
+ if err := m.setupOutput(dwlMgr, output); err != nil {
+ log.Warnf("DWL: Failed to setup output %d: %v", output.ID(), err)
+ }
+ }
+
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("final roundtrip failed: %w", err)
+ }
+
+ log.Info("DWL: registry setup complete")
+ return nil
+}
+
+func (m *Manager) setupOutput(manager *dwl_ipc.ZdwlIpcManagerV2, output *wlclient.Output) error {
+ m.wlMutex.Lock()
+ ipcOutput, err := manager.GetOutput(output)
+ m.wlMutex.Unlock()
+ if err != nil {
+ return fmt.Errorf("failed to get dwl output: %w", err)
+ }
+
+ m.outputsMutex.Lock()
+ outState, exists := m.outputs[output.ID()]
+ if !exists {
+ m.outputsMutex.Unlock()
+ return fmt.Errorf("output state not found for id %d", output.ID())
+ }
+ outState.ipcOutput = ipcOutput
+ m.outputsMutex.Unlock()
+
+ ipcOutput.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) {
+ outState.active = e.Active
+ })
+
+ ipcOutput.SetTagHandler(func(e dwl_ipc.ZdwlIpcOutputV2TagEvent) {
+ updated := false
+ for i, tag := range outState.tags {
+ if tag.Tag == e.Tag {
+ outState.tags[i] = TagState{
+ Tag: e.Tag,
+ State: e.State,
+ Clients: e.Clients,
+ Focused: e.Focused,
+ }
+ updated = true
+ break
+ }
+ }
+
+ if !updated {
+ outState.tags = append(outState.tags, TagState{
+ Tag: e.Tag,
+ State: e.State,
+ Clients: e.Clients,
+ Focused: e.Focused,
+ })
+ }
+
+ m.updateState()
+ })
+
+ ipcOutput.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutEvent) {
+ outState.layout = e.Layout
+ })
+
+ ipcOutput.SetTitleHandler(func(e dwl_ipc.ZdwlIpcOutputV2TitleEvent) {
+ outState.title = e.Title
+ })
+
+ ipcOutput.SetAppidHandler(func(e dwl_ipc.ZdwlIpcOutputV2AppidEvent) {
+ outState.appID = e.Appid
+ })
+
+ ipcOutput.SetLayoutSymbolHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutSymbolEvent) {
+ outState.layoutSymbol = e.Layout
+ })
+
+ ipcOutput.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) {
+ m.updateState()
+ })
+
+ return nil
+}
+
+func (m *Manager) updateState() {
+ m.outputsMutex.RLock()
+ outputs := make(map[string]*OutputState)
+ activeOutput := ""
+
+ for _, out := range m.outputs {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+
+ tagsCopy := make([]TagState, len(out.tags))
+ copy(tagsCopy, out.tags)
+
+ outputs[name] = &OutputState{
+ Name: name,
+ Active: out.active,
+ Tags: tagsCopy,
+ Layout: out.layout,
+ LayoutSymbol: out.layoutSymbol,
+ Title: out.title,
+ AppID: out.appID,
+ }
+
+ if out.active != 0 {
+ activeOutput = name
+ }
+ }
+ m.outputsMutex.RUnlock()
+
+ newState := State{
+ Outputs: outputs,
+ TagCount: m.tagCount,
+ Layouts: m.layouts,
+ ActiveOutput: activeOutput,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.subMutex.RLock()
+ subCount := len(m.subscribers)
+ m.subMutex.RUnlock()
+
+ if subCount == 0 {
+ pending = false
+ continue
+ }
+
+ currentState := m.GetState()
+
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) {
+ pending = false
+ continue
+ }
+
+ m.subMutex.RLock()
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- currentState:
+ default:
+ log.Warn("DWL: subscriber channel full, dropping update")
+ }
+ }
+ m.subMutex.RUnlock()
+
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) ensureOutputSetup(out *outputState) error {
+ if out.ipcOutput != nil {
+ return nil
+ }
+
+ return fmt.Errorf("output not yet initialized - setup in progress, retry in a moment")
+}
+
+func (m *Manager) SetTags(outputName string, tagmask uint32, toggleTagset uint32) error {
+ m.outputsMutex.RLock()
+
+ availableOutputs := make([]string, 0, len(m.outputs))
+ var targetOut *outputState
+ for _, out := range m.outputs {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+ availableOutputs = append(availableOutputs, name)
+ if name == outputName {
+ targetOut = out
+ break
+ }
+ }
+ m.outputsMutex.RUnlock()
+
+ if targetOut == nil {
+ return fmt.Errorf("output not found: %s (available: %v)", outputName, availableOutputs)
+ }
+
+ if err := m.ensureOutputSetup(targetOut); err != nil {
+ return fmt.Errorf("failed to setup output %s: %w", outputName, err)
+ }
+
+ ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
+ if !ok {
+ return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
+ }
+
+ m.wlMutex.Lock()
+ err := ipcOut.SetTags(tagmask, toggleTagset)
+ m.wlMutex.Unlock()
+ return err
+}
+
+func (m *Manager) SetClientTags(outputName string, andTags uint32, xorTags uint32) error {
+ m.outputsMutex.RLock()
+
+ var targetOut *outputState
+ for _, out := range m.outputs {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+ if name == outputName {
+ targetOut = out
+ break
+ }
+ }
+ m.outputsMutex.RUnlock()
+
+ if targetOut == nil {
+ return fmt.Errorf("output not found: %s", outputName)
+ }
+
+ if err := m.ensureOutputSetup(targetOut); err != nil {
+ return fmt.Errorf("failed to setup output %s: %w", outputName, err)
+ }
+
+ ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
+ if !ok {
+ return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
+ }
+
+ m.wlMutex.Lock()
+ err := ipcOut.SetClientTags(andTags, xorTags)
+ m.wlMutex.Unlock()
+ return err
+}
+
+func (m *Manager) SetLayout(outputName string, index uint32) error {
+ m.outputsMutex.RLock()
+
+ var targetOut *outputState
+ for _, out := range m.outputs {
+ name := out.name
+ if name == "" {
+ name = fmt.Sprintf("output-%d", out.id)
+ }
+ if name == outputName {
+ targetOut = out
+ break
+ }
+ }
+ m.outputsMutex.RUnlock()
+
+ if targetOut == nil {
+ return fmt.Errorf("output not found: %s", outputName)
+ }
+
+ if err := m.ensureOutputSetup(targetOut); err != nil {
+ return fmt.Errorf("failed to setup output %s: %w", outputName, err)
+ }
+
+ ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2)
+ if !ok {
+ return fmt.Errorf("output %s has invalid ipcOutput type", outputName)
+ }
+
+ m.wlMutex.Lock()
+ err := ipcOut.SetLayout(index)
+ m.wlMutex.Unlock()
+ return err
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan State)
+ m.subMutex.Unlock()
+
+ m.outputsMutex.Lock()
+ for _, out := range m.outputs {
+ if ipcOut, ok := out.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok {
+ ipcOut.Release()
+ }
+ }
+ m.outputs = make(map[uint32]*outputState)
+ m.outputsMutex.Unlock()
+
+ if mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2); ok {
+ mgr.Release()
+ }
+}
diff --git a/backend/internal/server/dwl/types.go b/backend/internal/server/dwl/types.go
new file mode 100644
index 00000000..a974df56
--- /dev/null
+++ b/backend/internal/server/dwl/types.go
@@ -0,0 +1,169 @@
+package dwl
+
+import (
+ "sync"
+
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+)
+
+type TagState struct {
+ Tag uint32 `json:"tag"`
+ State uint32 `json:"state"`
+ Clients uint32 `json:"clients"`
+ Focused uint32 `json:"focused"`
+}
+
+type OutputState struct {
+ Name string `json:"name"`
+ Active uint32 `json:"active"`
+ Tags []TagState `json:"tags"`
+ Layout uint32 `json:"layout"`
+ LayoutSymbol string `json:"layoutSymbol"`
+ Title string `json:"title"`
+ AppID string `json:"appId"`
+}
+
+type State struct {
+ Outputs map[string]*OutputState `json:"outputs"`
+ TagCount uint32 `json:"tagCount"`
+ Layouts []string `json:"layouts"`
+ ActiveOutput string `json:"activeOutput"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type Manager struct {
+ display *wlclient.Display
+ registry *wlclient.Registry
+ manager interface{}
+
+ outputs map[uint32]*outputState
+ outputsMutex sync.RWMutex
+
+ tagCount uint32
+ layouts []string
+
+ wlMutex sync.Mutex
+ cmdq chan cmd
+ outputSetupReq chan uint32
+ stopChan chan struct{}
+ wg sync.WaitGroup
+
+ subscribers map[string]chan State
+ subMutex sync.RWMutex
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ stateMutex sync.RWMutex
+ state *State
+}
+
+type outputState struct {
+ id uint32
+ registryName uint32
+ output *wlclient.Output
+ ipcOutput interface{}
+ name string
+ active uint32
+ tags []TagState
+ layout uint32
+ layoutSymbol string
+ title string
+ appID string
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{
+ Outputs: make(map[string]*OutputState),
+ Layouts: []string{},
+ TagCount: 0,
+ }
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.TagCount != new.TagCount {
+ return true
+ }
+ if len(old.Layouts) != len(new.Layouts) {
+ return true
+ }
+ if old.ActiveOutput != new.ActiveOutput {
+ return true
+ }
+ if len(old.Outputs) != len(new.Outputs) {
+ return true
+ }
+
+ for name, newOut := range new.Outputs {
+ oldOut, exists := old.Outputs[name]
+ if !exists {
+ return true
+ }
+ if oldOut.Active != newOut.Active {
+ return true
+ }
+ if oldOut.Layout != newOut.Layout {
+ return true
+ }
+ if oldOut.LayoutSymbol != newOut.LayoutSymbol {
+ return true
+ }
+ if oldOut.Title != newOut.Title {
+ return true
+ }
+ if oldOut.AppID != newOut.AppID {
+ return true
+ }
+ if len(oldOut.Tags) != len(newOut.Tags) {
+ return true
+ }
+ for i, newTag := range newOut.Tags {
+ if i >= len(oldOut.Tags) {
+ return true
+ }
+ oldTag := oldOut.Tags[i]
+ if oldTag.Tag != newTag.Tag || oldTag.State != newTag.State ||
+ oldTag.Clients != newTag.Clients || oldTag.Focused != newTag.Focused {
+ return true
+ }
+ }
+ }
+
+ return false
+}
diff --git a/backend/internal/server/extworkspace/handlers.go b/backend/internal/server/extworkspace/handlers.go
new file mode 100644
index 00000000..7654af8a
--- /dev/null
+++ b/backend/internal/server/extworkspace/handlers.go
@@ -0,0 +1,152 @@
+package extworkspace
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "extworkspace manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "extworkspace.getState":
+ handleGetState(conn, req, manager)
+ case "extworkspace.activateWorkspace":
+ handleActivateWorkspace(conn, req, manager)
+ case "extworkspace.deactivateWorkspace":
+ handleDeactivateWorkspace(conn, req, manager)
+ case "extworkspace.removeWorkspace":
+ handleRemoveWorkspace(conn, req, manager)
+ case "extworkspace.createWorkspace":
+ handleCreateWorkspace(conn, req, manager)
+ case "extworkspace.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleActivateWorkspace(conn net.Conn, req Request, manager *Manager) {
+ groupID, ok := req.Params["groupID"].(string)
+ if !ok {
+ groupID = ""
+ }
+
+ workspaceID, ok := req.Params["workspaceID"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
+ return
+ }
+
+ if err := manager.ActivateWorkspace(groupID, workspaceID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace activated"})
+}
+
+func handleDeactivateWorkspace(conn net.Conn, req Request, manager *Manager) {
+ groupID, ok := req.Params["groupID"].(string)
+ if !ok {
+ groupID = ""
+ }
+
+ workspaceID, ok := req.Params["workspaceID"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
+ return
+ }
+
+ if err := manager.DeactivateWorkspace(groupID, workspaceID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace deactivated"})
+}
+
+func handleRemoveWorkspace(conn net.Conn, req Request, manager *Manager) {
+ groupID, ok := req.Params["groupID"].(string)
+ if !ok {
+ groupID = ""
+ }
+
+ workspaceID, ok := req.Params["workspaceID"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
+ return
+ }
+
+ if err := manager.RemoveWorkspace(groupID, workspaceID); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace removed"})
+}
+
+func handleCreateWorkspace(conn net.Conn, req Request, manager *Manager) {
+ groupID, ok := req.Params["groupID"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter")
+ return
+ }
+
+ workspaceName, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ if err := manager.CreateWorkspace(groupID, workspaceName); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace create requested"})
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/backend/internal/server/extworkspace/manager.go b/backend/internal/server/extworkspace/manager.go
new file mode 100644
index 00000000..f2535e64
--- /dev/null
+++ b/backend/internal/server/extworkspace/manager.go
@@ -0,0 +1,566 @@
+package extworkspace
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/proto/ext_workspace"
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+)
+
+func NewManager(display *wlclient.Display) (*Manager, error) {
+ m := &Manager{
+ display: display,
+ outputs: make(map[uint32]*wlclient.Output),
+ outputNames: make(map[uint32]string),
+ groups: make(map[uint32]*workspaceGroupState),
+ workspaces: make(map[uint32]*workspaceState),
+ cmdq: make(chan cmd, 128),
+ stopChan: make(chan struct{}),
+ subscribers: make(map[string]chan State),
+ dirty: make(chan struct{}, 1),
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ if err := m.setupRegistry(); err != nil {
+ close(m.stopChan)
+ m.wg.Wait()
+ return nil, err
+ }
+
+ m.updateState()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("ExtWorkspace actor command queue full, dropping command")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ c.fn()
+ }
+ }
+}
+
+func (m *Manager) setupRegistry() error {
+ log.Info("ExtWorkspace: starting registry setup")
+ ctx := m.display.Context()
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ if e.Interface == "wl_output" {
+ output := wlclient.NewOutput(ctx)
+ if err := registry.Bind(e.Name, e.Interface, 4, output); err == nil {
+ outputID := output.ID()
+
+ output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
+ m.outputsMutex.Lock()
+ m.outputNames[outputID] = ev.Name
+ m.outputsMutex.Unlock()
+ log.Debugf("ExtWorkspace: Output %d (%s) name received", outputID, ev.Name)
+ })
+ }
+ return
+ }
+
+ if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName {
+ log.Infof("ExtWorkspace: found %s", ext_workspace.ExtWorkspaceManagerV1InterfaceName)
+ manager := ext_workspace.NewExtWorkspaceManagerV1(ctx)
+ version := e.Version
+ if version > 1 {
+ version = 1
+ }
+
+ manager.SetWorkspaceGroupHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
+ m.handleWorkspaceGroup(e)
+ })
+
+ manager.SetWorkspaceHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
+ m.handleWorkspace(e)
+ })
+
+ manager.SetDoneHandler(func(e ext_workspace.ExtWorkspaceManagerV1DoneEvent) {
+ log.Debug("ExtWorkspace: done event received")
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ manager.SetFinishedHandler(func(e ext_workspace.ExtWorkspaceManagerV1FinishedEvent) {
+ log.Info("ExtWorkspace: finished event received")
+ })
+
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ m.manager = manager
+ log.Info("ExtWorkspace: manager bound successfully")
+ } else {
+ log.Errorf("ExtWorkspace: failed to bind manager: %v", err)
+ }
+ }
+ })
+
+ log.Info("ExtWorkspace: registry setup complete (events will be processed async)")
+ return nil
+}
+
+func (m *Manager) handleWorkspaceGroup(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
+ handle := e.WorkspaceGroup
+ groupID := handle.ID()
+
+ log.Debugf("ExtWorkspace: New workspace group (id=%d)", groupID)
+
+ group := &workspaceGroupState{
+ id: groupID,
+ handle: handle,
+ outputIDs: make(map[uint32]bool),
+ workspaceIDs: make([]uint32, 0),
+ }
+
+ m.groupsMutex.Lock()
+ m.groups[groupID] = group
+ m.groupsMutex.Unlock()
+
+ handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1CapabilitiesEvent) {
+ log.Debugf("ExtWorkspace: Group %d capabilities: %d", groupID, e.Capabilities)
+ })
+
+ handle.SetOutputEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputEnterEvent) {
+ outputID := e.Output.ID()
+ log.Debugf("ExtWorkspace: Group %d output enter (output=%d)", groupID, outputID)
+
+ group.outputIDs[outputID] = true
+
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetOutputLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputLeaveEvent) {
+ outputID := e.Output.ID()
+ log.Debugf("ExtWorkspace: Group %d output leave (output=%d)", groupID, outputID)
+ delete(group.outputIDs, outputID)
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetWorkspaceEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceEnterEvent) {
+ workspaceID := e.Workspace.ID()
+ log.Debugf("ExtWorkspace: Group %d workspace enter (workspace=%d)", groupID, workspaceID)
+
+ m.workspacesMutex.Lock()
+ if ws, exists := m.workspaces[workspaceID]; exists {
+ ws.groupID = groupID
+ }
+ m.workspacesMutex.Unlock()
+
+ group.workspaceIDs = append(group.workspaceIDs, workspaceID)
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetWorkspaceLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent) {
+ workspaceID := e.Workspace.ID()
+ log.Debugf("ExtWorkspace: Group %d workspace leave (workspace=%d)", groupID, workspaceID)
+
+ m.workspacesMutex.Lock()
+ if ws, exists := m.workspaces[workspaceID]; exists {
+ ws.groupID = 0
+ }
+ m.workspacesMutex.Unlock()
+
+ for i, id := range group.workspaceIDs {
+ if id == workspaceID {
+ group.workspaceIDs = append(group.workspaceIDs[:i], group.workspaceIDs[i+1:]...)
+ break
+ }
+ }
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1RemovedEvent) {
+ log.Debugf("ExtWorkspace: Group %d removed", groupID)
+ group.removed = true
+
+ m.groupsMutex.Lock()
+ delete(m.groups, groupID)
+ m.groupsMutex.Unlock()
+
+ m.post(func() {
+ m.wlMutex.Lock()
+ handle.Destroy()
+ m.wlMutex.Unlock()
+
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) handleWorkspace(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
+ handle := e.Workspace
+ workspaceID := handle.ID()
+
+ log.Debugf("ExtWorkspace: New workspace (proxy_id=%d)", workspaceID)
+
+ ws := &workspaceState{
+ id: workspaceID,
+ handle: handle,
+ coordinates: make([]uint32, 0),
+ }
+
+ m.workspacesMutex.Lock()
+ m.workspaces[workspaceID] = ws
+ m.workspacesMutex.Unlock()
+
+ handle.SetIdHandler(func(e ext_workspace.ExtWorkspaceHandleV1IdEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d id: %s", workspaceID, e.Id)
+ ws.workspaceID = e.Id
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetNameHandler(func(e ext_workspace.ExtWorkspaceHandleV1NameEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d name: %s", workspaceID, e.Name)
+ ws.name = e.Name
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetCoordinatesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CoordinatesEvent) {
+ coords := make([]uint32, 0)
+ for i := 0; i < len(e.Coordinates); i += 4 {
+ if i+4 <= len(e.Coordinates) {
+ val := uint32(e.Coordinates[i]) |
+ uint32(e.Coordinates[i+1])<<8 |
+ uint32(e.Coordinates[i+2])<<16 |
+ uint32(e.Coordinates[i+3])<<24
+ coords = append(coords, val)
+ }
+ }
+ log.Debugf("ExtWorkspace: Workspace %d coordinates: %v", workspaceID, coords)
+ ws.coordinates = coords
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetStateHandler(func(e ext_workspace.ExtWorkspaceHandleV1StateEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d state: %d", workspaceID, e.State)
+ ws.state = e.State
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CapabilitiesEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d capabilities: %d", workspaceID, e.Capabilities)
+ })
+
+ handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceHandleV1RemovedEvent) {
+ log.Debugf("ExtWorkspace: Workspace %d removed", workspaceID)
+ ws.removed = true
+
+ m.workspacesMutex.Lock()
+ delete(m.workspaces, workspaceID)
+ m.workspacesMutex.Unlock()
+
+ m.post(func() {
+ m.wlMutex.Lock()
+ handle.Destroy()
+ m.wlMutex.Unlock()
+
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) updateState() {
+ m.groupsMutex.RLock()
+ m.workspacesMutex.RLock()
+
+ groups := make([]*WorkspaceGroup, 0)
+
+ for _, group := range m.groups {
+ if group.removed {
+ continue
+ }
+
+ outputs := make([]string, 0)
+ for outputID := range group.outputIDs {
+ m.outputsMutex.RLock()
+ name := m.outputNames[outputID]
+ m.outputsMutex.RUnlock()
+ if name != "" {
+ outputs = append(outputs, name)
+ } else {
+ outputs = append(outputs, fmt.Sprintf("output-%d", outputID))
+ }
+ }
+
+ workspaces := make([]*Workspace, 0)
+ for _, wsID := range group.workspaceIDs {
+ ws, exists := m.workspaces[wsID]
+ if !exists || ws.removed {
+ continue
+ }
+
+ workspace := &Workspace{
+ ID: ws.workspaceID,
+ Name: ws.name,
+ Coordinates: ws.coordinates,
+ State: ws.state,
+ Active: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateActive) != 0,
+ Urgent: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateUrgent) != 0,
+ Hidden: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateHidden) != 0,
+ }
+ workspaces = append(workspaces, workspace)
+ }
+
+ groupState := &WorkspaceGroup{
+ ID: fmt.Sprintf("group-%d", group.id),
+ Outputs: outputs,
+ Workspaces: workspaces,
+ }
+ groups = append(groups, groupState)
+ }
+
+ m.workspacesMutex.RUnlock()
+ m.groupsMutex.RUnlock()
+
+ newState := State{
+ Groups: groups,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.subMutex.RLock()
+ subCount := len(m.subscribers)
+ m.subMutex.RUnlock()
+
+ if subCount == 0 {
+ pending = false
+ continue
+ }
+
+ currentState := m.GetState()
+
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) {
+ pending = false
+ continue
+ }
+
+ m.subMutex.RLock()
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- currentState:
+ default:
+ log.Warn("ExtWorkspace: subscriber channel full, dropping update")
+ }
+ }
+ m.subMutex.RUnlock()
+
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) ActivateWorkspace(groupID, workspaceID string) error {
+ m.workspacesMutex.RLock()
+ defer m.workspacesMutex.RUnlock()
+
+ var targetGroupID uint32
+ if groupID != "" {
+ var parsedID uint32
+ if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
+ targetGroupID = parsedID
+ }
+ }
+
+ for _, ws := range m.workspaces {
+ if targetGroupID != 0 && ws.groupID != targetGroupID {
+ continue
+ }
+ if ws.workspaceID == workspaceID || ws.name == workspaceID {
+ m.wlMutex.Lock()
+ err := ws.handle.Activate()
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ return err
+ }
+ }
+
+ return fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
+}
+
+func (m *Manager) DeactivateWorkspace(groupID, workspaceID string) error {
+ m.workspacesMutex.RLock()
+ defer m.workspacesMutex.RUnlock()
+
+ var targetGroupID uint32
+ if groupID != "" {
+ var parsedID uint32
+ if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
+ targetGroupID = parsedID
+ }
+ }
+
+ for _, ws := range m.workspaces {
+ if targetGroupID != 0 && ws.groupID != targetGroupID {
+ continue
+ }
+ if ws.workspaceID == workspaceID || ws.name == workspaceID {
+ m.wlMutex.Lock()
+ err := ws.handle.Deactivate()
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ return err
+ }
+ }
+
+ return fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
+}
+
+func (m *Manager) RemoveWorkspace(groupID, workspaceID string) error {
+ m.workspacesMutex.RLock()
+ defer m.workspacesMutex.RUnlock()
+
+ var targetGroupID uint32
+ if groupID != "" {
+ var parsedID uint32
+ if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
+ targetGroupID = parsedID
+ }
+ }
+
+ for _, ws := range m.workspaces {
+ if targetGroupID != 0 && ws.groupID != targetGroupID {
+ continue
+ }
+ if ws.workspaceID == workspaceID || ws.name == workspaceID {
+ m.wlMutex.Lock()
+ err := ws.handle.Remove()
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ return err
+ }
+ }
+
+ return fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
+}
+
+func (m *Manager) CreateWorkspace(groupID, workspaceName string) error {
+ m.groupsMutex.RLock()
+ defer m.groupsMutex.RUnlock()
+
+ for _, group := range m.groups {
+ if fmt.Sprintf("group-%d", group.id) == groupID {
+ m.wlMutex.Lock()
+ err := group.handle.CreateWorkspace(workspaceName)
+ if err == nil {
+ err = m.manager.Commit()
+ }
+ m.wlMutex.Unlock()
+ return err
+ }
+ }
+
+ return fmt.Errorf("workspace group not found: %s", groupID)
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan State)
+ m.subMutex.Unlock()
+
+ m.workspacesMutex.Lock()
+ for _, ws := range m.workspaces {
+ if ws.handle != nil {
+ ws.handle.Destroy()
+ }
+ }
+ m.workspaces = make(map[uint32]*workspaceState)
+ m.workspacesMutex.Unlock()
+
+ m.groupsMutex.Lock()
+ for _, group := range m.groups {
+ if group.handle != nil {
+ group.handle.Destroy()
+ }
+ }
+ m.groups = make(map[uint32]*workspaceGroupState)
+ m.groupsMutex.Unlock()
+
+ if m.manager != nil {
+ m.manager.Stop()
+ }
+}
diff --git a/backend/internal/server/extworkspace/types.go b/backend/internal/server/extworkspace/types.go
new file mode 100644
index 00000000..68aa534f
--- /dev/null
+++ b/backend/internal/server/extworkspace/types.go
@@ -0,0 +1,175 @@
+package extworkspace
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/proto/ext_workspace"
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+)
+
+type Workspace struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Coordinates []uint32 `json:"coordinates"`
+ State uint32 `json:"state"`
+ Active bool `json:"active"`
+ Urgent bool `json:"urgent"`
+ Hidden bool `json:"hidden"`
+}
+
+type WorkspaceGroup struct {
+ ID string `json:"id"`
+ Outputs []string `json:"outputs"`
+ Workspaces []*Workspace `json:"workspaces"`
+}
+
+type State struct {
+ Groups []*WorkspaceGroup `json:"groups"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type Manager struct {
+ display *wlclient.Display
+ registry *wlclient.Registry
+ manager *ext_workspace.ExtWorkspaceManagerV1
+
+ outputsMutex sync.RWMutex
+ outputs map[uint32]*wlclient.Output
+ outputNames map[uint32]string
+
+ groupsMutex sync.RWMutex
+ groups map[uint32]*workspaceGroupState
+
+ workspacesMutex sync.RWMutex
+ workspaces map[uint32]*workspaceState
+
+ wlMutex sync.Mutex
+ cmdq chan cmd
+ stopChan chan struct{}
+ wg sync.WaitGroup
+
+ subscribers map[string]chan State
+ subMutex sync.RWMutex
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ stateMutex sync.RWMutex
+ state *State
+}
+
+type workspaceGroupState struct {
+ id uint32
+ handle *ext_workspace.ExtWorkspaceGroupHandleV1
+ outputIDs map[uint32]bool
+ workspaceIDs []uint32
+ removed bool
+}
+
+type workspaceState struct {
+ id uint32
+ handle *ext_workspace.ExtWorkspaceHandleV1
+ workspaceID string
+ name string
+ coordinates []uint32
+ state uint32
+ groupID uint32
+ removed bool
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{
+ Groups: []*WorkspaceGroup{},
+ }
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if len(old.Groups) != len(new.Groups) {
+ return true
+ }
+
+ for i, newGroup := range new.Groups {
+ if i >= len(old.Groups) {
+ return true
+ }
+ oldGroup := old.Groups[i]
+ if oldGroup.ID != newGroup.ID {
+ return true
+ }
+ if len(oldGroup.Outputs) != len(newGroup.Outputs) {
+ return true
+ }
+ for j, newOutput := range newGroup.Outputs {
+ if j >= len(oldGroup.Outputs) {
+ return true
+ }
+ if oldGroup.Outputs[j] != newOutput {
+ return true
+ }
+ }
+ if len(oldGroup.Workspaces) != len(newGroup.Workspaces) {
+ return true
+ }
+ for j, newWs := range newGroup.Workspaces {
+ if j >= len(oldGroup.Workspaces) {
+ return true
+ }
+ oldWs := oldGroup.Workspaces[j]
+ if oldWs.ID != newWs.ID || oldWs.Name != newWs.Name || oldWs.State != newWs.State {
+ return true
+ }
+ if oldWs.Active != newWs.Active || oldWs.Urgent != newWs.Urgent || oldWs.Hidden != newWs.Hidden {
+ return true
+ }
+ if len(oldWs.Coordinates) != len(newWs.Coordinates) {
+ return true
+ }
+ for k, coord := range newWs.Coordinates {
+ if k >= len(oldWs.Coordinates) {
+ return true
+ }
+ if oldWs.Coordinates[k] != coord {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+}
diff --git a/backend/internal/server/freedesktop/actions.go b/backend/internal/server/freedesktop/actions.go
new file mode 100644
index 00000000..c187e2ea
--- /dev/null
+++ b/backend/internal/server/freedesktop/actions.go
@@ -0,0 +1,128 @@
+package freedesktop
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func (m *Manager) SetIconFile(iconPath string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetIconFile", 0, iconPath).Err
+ if err != nil {
+ return fmt.Errorf("failed to set icon file: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetRealName(name string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetRealName", 0, name).Err
+ if err != nil {
+ return fmt.Errorf("failed to set real name: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetEmail(email string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetEmail", 0, email).Err
+ if err != nil {
+ return fmt.Errorf("failed to set email: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetLanguage(language string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLanguage", 0, language).Err
+ if err != nil {
+ return fmt.Errorf("failed to set language: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) SetLocation(location string) error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLocation", 0, location).Err
+ if err != nil {
+ return fmt.Errorf("failed to set location: %w", err)
+ }
+
+ m.updateAccountsState()
+ return nil
+}
+
+func (m *Manager) GetUserIconFile(username string) (string, error) {
+ if m.systemConn == nil {
+ return "", fmt.Errorf("accounts service not available")
+ }
+
+ accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath))
+
+ var userPath dbus.ObjectPath
+ err := accountsManager.Call(dbusAccountsInterface+".FindUserByName", 0, username).Store(&userPath)
+ if err != nil {
+ return "", fmt.Errorf("user not found: %w", err)
+ }
+
+ userObj := m.systemConn.Object(dbusAccountsDest, userPath)
+ variant, err := userObj.GetProperty(dbusAccountsUserInterface + ".IconFile")
+ if err != nil {
+ return "", err
+ }
+
+ var iconFile string
+ if err := variant.Store(&iconFile); err != nil {
+ return "", err
+ }
+
+ return iconFile, nil
+}
+
+func (m *Manager) SetIconTheme(iconTheme string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ check := exec.CommandContext(ctx, "gsettings", "writable", "org.gnome.desktop.interface", "icon-theme")
+ if err := check.Run(); err == nil {
+ cmd := exec.CommandContext(ctx, "gsettings", "set", "org.gnome.desktop.interface", "icon-theme", iconTheme)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("gsettings set failed: %w", err)
+ }
+ return nil
+ }
+
+ checkDconf := exec.CommandContext(ctx, "dconf", "write", "/org/gnome/desktop/interface/icon-theme", fmt.Sprintf("'%s'", iconTheme))
+ if err := checkDconf.Run(); err != nil {
+ return fmt.Errorf("both gsettings and dconf unavailable or failed: %w", err)
+ }
+
+ return nil
+}
diff --git a/backend/internal/server/freedesktop/actions_test.go b/backend/internal/server/freedesktop/actions_test.go
new file mode 100644
index 00000000..e2c21e8f
--- /dev/null
+++ b/backend/internal/server/freedesktop/actions_test.go
@@ -0,0 +1,145 @@
+package freedesktop
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_SetIconFile(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetIconFile("/path/to/icon.png")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetRealName(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetRealName("New Name")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetEmail(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetEmail("test@example.com")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetLanguage(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetLanguage("en_US.UTF-8")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_SetLocation(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.SetLocation("Test Location")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_GetUserIconFile(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ iconFile, err := manager.GetUserIconFile("testuser")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ assert.Empty(t, iconFile)
+ })
+}
+
+func TestManager_UpdateAccountsState(t *testing.T) {
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.updateAccountsState()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "accounts service not available")
+ })
+}
+
+func TestManager_UpdateSettingsState(t *testing.T) {
+ t.Run("settings not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ err := manager.updateSettingsState()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "settings portal not available")
+ })
+}
diff --git a/backend/internal/server/freedesktop/constants.go b/backend/internal/server/freedesktop/constants.go
new file mode 100644
index 00000000..e777ee24
--- /dev/null
+++ b/backend/internal/server/freedesktop/constants.go
@@ -0,0 +1,14 @@
+package freedesktop
+
+const (
+ dbusAccountsDest = "org.freedesktop.Accounts"
+ dbusAccountsPath = "/org/freedesktop/Accounts"
+ dbusAccountsInterface = "org.freedesktop.Accounts"
+ dbusAccountsUserInterface = "org.freedesktop.Accounts.User"
+
+ dbusPortalDest = "org.freedesktop.portal.Desktop"
+ dbusPortalPath = "/org/freedesktop/portal/desktop"
+ dbusPortalSettingsInterface = "org.freedesktop.portal.Settings"
+
+ dbusPropsInterface = "org.freedesktop.DBus.Properties"
+)
diff --git a/backend/internal/server/freedesktop/handlers.go b/backend/internal/server/freedesktop/handlers.go
new file mode 100644
index 00000000..13dfb42e
--- /dev/null
+++ b/backend/internal/server/freedesktop/handlers.go
@@ -0,0 +1,166 @@
+package freedesktop
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Value string `json:"value,omitempty"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ switch req.Method {
+ case "freedesktop.getState":
+ handleGetState(conn, req, manager)
+ case "freedesktop.accounts.setIconFile":
+ handleSetIconFile(conn, req, manager)
+ case "freedesktop.accounts.setRealName":
+ handleSetRealName(conn, req, manager)
+ case "freedesktop.accounts.setEmail":
+ handleSetEmail(conn, req, manager)
+ case "freedesktop.accounts.setLanguage":
+ handleSetLanguage(conn, req, manager)
+ case "freedesktop.accounts.setLocation":
+ handleSetLocation(conn, req, manager)
+ case "freedesktop.accounts.getUserIconFile":
+ handleGetUserIconFile(conn, req, manager)
+ case "freedesktop.settings.getColorScheme":
+ handleGetColorScheme(conn, req, manager)
+ case "freedesktop.settings.setIconTheme":
+ handleSetIconTheme(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleSetIconFile(conn net.Conn, req Request, manager *Manager) {
+ iconPath, ok := req.Params["path"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'path' parameter")
+ return
+ }
+
+ if err := manager.SetIconFile(iconPath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "icon file set"})
+}
+
+func handleSetRealName(conn net.Conn, req Request, manager *Manager) {
+ name, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ if err := manager.SetRealName(name); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "real name set"})
+}
+
+func handleSetEmail(conn net.Conn, req Request, manager *Manager) {
+ email, ok := req.Params["email"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'email' parameter")
+ return
+ }
+
+ if err := manager.SetEmail(email); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "email set"})
+}
+
+func handleSetLanguage(conn net.Conn, req Request, manager *Manager) {
+ language, ok := req.Params["language"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'language' parameter")
+ return
+ }
+
+ if err := manager.SetLanguage(language); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "language set"})
+}
+
+func handleSetLocation(conn net.Conn, req Request, manager *Manager) {
+ location, ok := req.Params["location"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'location' parameter")
+ return
+ }
+
+ if err := manager.SetLocation(location); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "location set"})
+}
+
+func handleGetUserIconFile(conn net.Conn, req Request, manager *Manager) {
+ username, ok := req.Params["username"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'username' parameter")
+ return
+ }
+
+ iconFile, err := manager.GetUserIconFile(username)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Value: iconFile})
+}
+
+func handleGetColorScheme(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.updateSettingsState(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ state := manager.GetState()
+ models.Respond(conn, req.ID, map[string]uint32{"colorScheme": state.Settings.ColorScheme})
+}
+
+func handleSetIconTheme(conn net.Conn, req Request, manager *Manager) {
+ iconTheme, ok := req.Params["iconTheme"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'iconTheme' parameter")
+ return
+ }
+
+ if err := manager.SetIconTheme(iconTheme); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "icon theme set"})
+}
diff --git a/backend/internal/server/freedesktop/handlers_test.go b/backend/internal/server/freedesktop/handlers_test.go
new file mode 100644
index 00000000..4102d196
--- /dev/null
+++ b/backend/internal/server/freedesktop/handlers_test.go
@@ -0,0 +1,581 @@
+package freedesktop
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "sync"
+ "testing"
+
+ mockdbus "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func mockGetAllAccountsProperties() *dbus.Call {
+ props := map[string]dbus.Variant{
+ "IconFile": dbus.MakeVariant("/path/to/icon.png"),
+ "RealName": dbus.MakeVariant("Test"),
+ "UserName": dbus.MakeVariant("test"),
+ "AccountType": dbus.MakeVariant(int32(0)),
+ "HomeDirectory": dbus.MakeVariant("/home/test"),
+ "Shell": dbus.MakeVariant("/bin/bash"),
+ "Email": dbus.MakeVariant(""),
+ "Language": dbus.MakeVariant(""),
+ "Location": dbus.MakeVariant(""),
+ "Locked": dbus.MakeVariant(false),
+ "PasswordMode": dbus.MakeVariant(int32(1)),
+ }
+ return &dbus.Call{Err: nil, Body: []interface{}{props}}
+}
+
+func TestRespondError_Freedesktop(t *testing.T) {
+ conn := newMockNetConn()
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond_Freedesktop(t *testing.T) {
+ conn := newMockNetConn()
+ result := SuccessResult{Success: true, Message: "test"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "test", resp.Result.Message)
+}
+
+func TestHandleGetState(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ RealName: "Test User",
+ UID: 1000,
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 1,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "freedesktop.getState"}
+
+ handleGetState(conn, req, manager)
+
+ var resp models.Response[FreedeskState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Accounts.Available)
+ assert.Equal(t, "testuser", resp.Result.Accounts.UserName)
+ assert.True(t, resp.Result.Settings.Available)
+ assert.Equal(t, uint32(1), resp.Result.Settings.ColorScheme)
+}
+
+func TestHandleSetIconFile(t *testing.T) {
+ t.Run("missing path parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setIconFile",
+ Params: map[string]interface{}{},
+ }
+
+ handleSetIconFile(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'path' parameter")
+ })
+
+ t.Run("successful set icon file", func(t *testing.T) {
+ mockAccountsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetIconFile", dbus.Flags(0), "/path/to/icon.png").Return(mockCall)
+ mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties())
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ accountsObj: mockAccountsObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setIconFile",
+ Params: map[string]interface{}{
+ "path": "/path/to/icon.png",
+ },
+ }
+
+ handleSetIconFile(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "icon file set", resp.Result.Message)
+ })
+
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setIconFile",
+ Params: map[string]interface{}{
+ "path": "/path/to/icon.png",
+ },
+ }
+
+ handleSetIconFile(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "accounts service not available")
+ })
+}
+
+func TestHandleSetRealName(t *testing.T) {
+ t.Run("missing name parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setRealName",
+ Params: map[string]interface{}{},
+ }
+
+ handleSetRealName(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'name' parameter")
+ })
+
+ t.Run("successful set real name", func(t *testing.T) {
+ mockAccountsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetRealName", dbus.Flags(0), "New Name").Return(mockCall)
+ mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties())
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ accountsObj: mockAccountsObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setRealName",
+ Params: map[string]interface{}{
+ "name": "New Name",
+ },
+ }
+
+ handleSetRealName(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "real name set", resp.Result.Message)
+ })
+}
+
+func TestHandleSetEmail(t *testing.T) {
+ t.Run("missing email parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setEmail",
+ Params: map[string]interface{}{},
+ }
+
+ handleSetEmail(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'email' parameter")
+ })
+
+ t.Run("successful set email", func(t *testing.T) {
+ mockAccountsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetEmail", dbus.Flags(0), "test@example.com").Return(mockCall)
+ mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties())
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ accountsObj: mockAccountsObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setEmail",
+ Params: map[string]interface{}{
+ "email": "test@example.com",
+ },
+ }
+
+ handleSetEmail(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "email set", resp.Result.Message)
+ })
+}
+
+func TestHandleSetLanguage(t *testing.T) {
+ t.Run("missing language parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setLanguage",
+ Params: map[string]interface{}{},
+ }
+
+ handleSetLanguage(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'language' parameter")
+ })
+}
+
+func TestHandleSetLocation(t *testing.T) {
+ t.Run("missing location parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.setLocation",
+ Params: map[string]interface{}{},
+ }
+
+ handleSetLocation(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'location' parameter")
+ })
+}
+
+func TestHandleGetUserIconFile(t *testing.T) {
+ t.Run("missing username parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.getUserIconFile",
+ Params: map[string]interface{}{},
+ }
+
+ handleGetUserIconFile(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'username' parameter")
+ })
+
+ t.Run("accounts not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.accounts.getUserIconFile",
+ Params: map[string]interface{}{
+ "username": "testuser",
+ },
+ }
+
+ handleGetUserIconFile(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "accounts service not available")
+ })
+}
+
+func TestHandleGetColorScheme(t *testing.T) {
+ t.Run("settings not available", func(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: false,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "freedesktop.settings.getColorScheme"}
+
+ handleGetColorScheme(conn, req, manager)
+
+ var resp models.Response[map[string]uint32]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "settings portal not available")
+ })
+
+ t.Run("successful get color scheme", func(t *testing.T) {
+ mockSettingsObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{
+ Err: nil,
+ Body: []interface{}{dbus.MakeVariant(uint32(1))},
+ }
+ mockSettingsObj.EXPECT().Call("org.freedesktop.portal.Settings.ReadOne", dbus.Flags(0), "org.freedesktop.appearance", "color-scheme").Return(mockCall)
+
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: true,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ settingsObj: mockSettingsObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "freedesktop.settings.getColorScheme"}
+
+ handleGetColorScheme(conn, req, manager)
+
+ var resp models.Response[map[string]uint32]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, uint32(1), (*resp.Result)["colorScheme"])
+ })
+}
+
+func TestHandleRequest(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ t.Run("unknown method", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.unknown",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+
+ t.Run("valid method - getState", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "freedesktop.getState",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[FreedeskState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ })
+
+ t.Run("all method routes", func(t *testing.T) {
+ tests := []string{
+ "freedesktop.accounts.setIconFile",
+ "freedesktop.accounts.setRealName",
+ "freedesktop.accounts.setEmail",
+ "freedesktop.accounts.setLanguage",
+ "freedesktop.accounts.setLocation",
+ "freedesktop.accounts.getUserIconFile",
+ "freedesktop.settings.getColorScheme",
+ }
+
+ for _, method := range tests {
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: method,
+ Params: map[string]interface{}{},
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ // Will have errors due to missing params or service unavailable
+ // but the method routing should work
+ }
+ })
+}
diff --git a/backend/internal/server/freedesktop/manager.go b/backend/internal/server/freedesktop/manager.go
new file mode 100644
index 00000000..ef85b967
--- /dev/null
+++ b/backend/internal/server/freedesktop/manager.go
@@ -0,0 +1,251 @@
+package freedesktop
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func NewManager() (*Manager, error) {
+ systemConn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ sessionConn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ sessionConn = nil
+ }
+
+ m := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{},
+ Settings: SettingsState{},
+ },
+ stateMutex: sync.RWMutex{},
+ systemConn: systemConn,
+ sessionConn: sessionConn,
+ currentUID: uint64(os.Getuid()),
+ subscribers: make(map[string]chan FreedeskState),
+ subMutex: sync.RWMutex{},
+ }
+
+ m.initializeAccounts()
+ m.initializeSettings()
+
+ return m, nil
+}
+
+func (m *Manager) initializeAccounts() error {
+ accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath))
+
+ var userPath dbus.ObjectPath
+ err := accountsManager.Call(dbusAccountsInterface+".FindUserById", 0, int64(m.currentUID)).Store(&userPath)
+ if err != nil {
+ m.stateMutex.Lock()
+ m.state.Accounts.Available = false
+ m.stateMutex.Unlock()
+ return err
+ }
+
+ m.accountsObj = m.systemConn.Object(dbusAccountsDest, userPath)
+
+ m.stateMutex.Lock()
+ m.state.Accounts.Available = true
+ m.state.Accounts.UserPath = string(userPath)
+ m.state.Accounts.UID = m.currentUID
+ m.stateMutex.Unlock()
+
+ if err := m.updateAccountsState(); err != nil {
+ return fmt.Errorf("failed to update accounts state: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) initializeSettings() error {
+ if m.sessionConn == nil {
+ m.stateMutex.Lock()
+ m.state.Settings.Available = false
+ m.stateMutex.Unlock()
+ return fmt.Errorf("no session bus connection")
+ }
+
+ m.settingsObj = m.sessionConn.Object(dbusPortalDest, dbus.ObjectPath(dbusPortalPath))
+
+ var variant dbus.Variant
+ err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant)
+ if err != nil {
+ m.stateMutex.Lock()
+ m.state.Settings.Available = false
+ m.stateMutex.Unlock()
+ return err
+ }
+
+ m.stateMutex.Lock()
+ m.state.Settings.Available = true
+ m.stateMutex.Unlock()
+
+ if err := m.updateSettingsState(); err != nil {
+ return fmt.Errorf("failed to update settings state: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) updateAccountsState() error {
+ if !m.state.Accounts.Available || m.accountsObj == nil {
+ return fmt.Errorf("accounts service not available")
+ }
+
+ ctx := context.Background()
+ props, err := m.getAccountProperties(ctx)
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ defer m.stateMutex.Unlock()
+
+ if v, ok := props["IconFile"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.IconFile = val
+ }
+ }
+ if v, ok := props["RealName"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.RealName = val
+ }
+ }
+ if v, ok := props["UserName"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.UserName = val
+ }
+ }
+ if v, ok := props["AccountType"]; ok {
+ if val, ok := v.Value().(int32); ok {
+ m.state.Accounts.AccountType = val
+ }
+ }
+ if v, ok := props["HomeDirectory"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.HomeDirectory = val
+ }
+ }
+ if v, ok := props["Shell"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.Shell = val
+ }
+ }
+ if v, ok := props["Email"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.Email = val
+ }
+ }
+ if v, ok := props["Language"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.Language = val
+ }
+ }
+ if v, ok := props["Location"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Accounts.Location = val
+ }
+ }
+ if v, ok := props["Locked"]; ok {
+ if val, ok := v.Value().(bool); ok {
+ m.state.Accounts.Locked = val
+ }
+ }
+ if v, ok := props["PasswordMode"]; ok {
+ if val, ok := v.Value().(int32); ok {
+ m.state.Accounts.PasswordMode = val
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) updateSettingsState() error {
+ if !m.state.Settings.Available || m.settingsObj == nil {
+ return fmt.Errorf("settings portal not available")
+ }
+
+ var variant dbus.Variant
+ err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant)
+ if err != nil {
+ return err
+ }
+
+ if colorScheme, ok := variant.Value().(uint32); ok {
+ m.stateMutex.Lock()
+ m.state.Settings.ColorScheme = colorScheme
+ m.stateMutex.Unlock()
+ }
+
+ return nil
+}
+
+func (m *Manager) getAccountProperties(ctx context.Context) (map[string]dbus.Variant, error) {
+ var props map[string]dbus.Variant
+ err := m.accountsObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusAccountsUserInterface).Store(&props)
+ if err != nil {
+ return nil, err
+ }
+ return props, nil
+}
+
+func (m *Manager) GetState() FreedeskState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return *m.state
+}
+
+func (m *Manager) Subscribe(id string) chan FreedeskState {
+ ch := make(chan FreedeskState, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) NotifySubscribers() {
+ m.subMutex.RLock()
+ defer m.subMutex.RUnlock()
+
+ state := m.GetState()
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- state:
+ default:
+ }
+ }
+}
+
+func (m *Manager) Close() {
+ m.subMutex.Lock()
+ for id, ch := range m.subscribers {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+
+ if m.systemConn != nil {
+ m.systemConn.Close()
+ }
+ if m.sessionConn != nil {
+ m.sessionConn.Close()
+ }
+}
diff --git a/backend/internal/server/freedesktop/manager_test.go b/backend/internal/server/freedesktop/manager_test.go
new file mode 100644
index 00000000..b3617f5e
--- /dev/null
+++ b/backend/internal/server/freedesktop/manager_test.go
@@ -0,0 +1,143 @@
+package freedesktop
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetState(t *testing.T) {
+ state := &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ RealName: "Test User",
+ UID: 1000,
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 1,
+ },
+ }
+
+ manager := &Manager{
+ state: state,
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.True(t, result.Accounts.Available)
+ assert.Equal(t, "testuser", result.Accounts.UserName)
+ assert.Equal(t, "Test User", result.Accounts.RealName)
+ assert.Equal(t, uint64(1000), result.Accounts.UID)
+ assert.True(t, result.Settings.Available)
+ assert.Equal(t, uint32(1), result.Settings.ColorScheme)
+}
+
+func TestManager_GetState_ThreadSafe(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 1,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ done := make(chan bool)
+ for i := 0; i < 10; i++ {
+ go func() {
+ state := manager.GetState()
+ assert.True(t, state.Accounts.Available)
+ assert.Equal(t, "testuser", state.Accounts.UserName)
+ done <- true
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ <-done
+ }
+}
+
+func TestManager_Close(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ systemConn: nil,
+ sessionConn: nil,
+ }
+
+ assert.NotPanics(t, func() {
+ manager.Close()
+ })
+}
+
+func TestNewManager(t *testing.T) {
+ t.Run("attempts to create manager", func(t *testing.T) {
+ manager, err := NewManager()
+ if err != nil {
+ assert.Nil(t, manager)
+ } else {
+ assert.NotNil(t, manager)
+ assert.NotNil(t, manager.state)
+ assert.NotNil(t, manager.systemConn)
+
+ manager.Close()
+ }
+ })
+}
+
+func TestManager_GetState_EmptyState(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.False(t, result.Accounts.Available)
+ assert.Empty(t, result.Accounts.UserName)
+ assert.False(t, result.Settings.Available)
+ assert.Equal(t, uint32(0), result.Settings.ColorScheme)
+}
+
+func TestManager_AccountsState_Modification(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ state := manager.GetState()
+ state.Accounts.UserName = "modifieduser"
+
+ original := manager.GetState()
+ assert.Equal(t, "testuser", original.Accounts.UserName)
+}
+
+func TestManager_SettingsState_Modification(t *testing.T) {
+ manager := &Manager{
+ state: &FreedeskState{
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 0,
+ },
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ state := manager.GetState()
+ state.Settings.ColorScheme = 1
+
+ original := manager.GetState()
+ assert.Equal(t, uint32(0), original.Settings.ColorScheme)
+}
diff --git a/backend/internal/server/freedesktop/types.go b/backend/internal/server/freedesktop/types.go
new file mode 100644
index 00000000..e46a0e8b
--- /dev/null
+++ b/backend/internal/server/freedesktop/types.go
@@ -0,0 +1,46 @@
+package freedesktop
+
+import (
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+)
+
+type AccountsState struct {
+ Available bool `json:"available"`
+ UserPath string `json:"userPath"`
+ IconFile string `json:"iconFile"`
+ RealName string `json:"realName"`
+ UserName string `json:"userName"`
+ AccountType int32 `json:"accountType"`
+ HomeDirectory string `json:"homeDirectory"`
+ Shell string `json:"shell"`
+ Email string `json:"email"`
+ Language string `json:"language"`
+ Location string `json:"location"`
+ Locked bool `json:"locked"`
+ PasswordMode int32 `json:"passwordMode"`
+ UID uint64 `json:"uid"`
+}
+
+type SettingsState struct {
+ Available bool `json:"available"`
+ ColorScheme uint32 `json:"colorScheme"`
+}
+
+type FreedeskState struct {
+ Accounts AccountsState `json:"accounts"`
+ Settings SettingsState `json:"settings"`
+}
+
+type Manager struct {
+ state *FreedeskState
+ stateMutex sync.RWMutex
+ systemConn *dbus.Conn
+ sessionConn *dbus.Conn
+ accountsObj dbus.BusObject
+ settingsObj dbus.BusObject
+ currentUID uint64
+ subscribers map[string]chan FreedeskState
+ subMutex sync.RWMutex
+}
diff --git a/backend/internal/server/freedesktop/types_test.go b/backend/internal/server/freedesktop/types_test.go
new file mode 100644
index 00000000..deb0327b
--- /dev/null
+++ b/backend/internal/server/freedesktop/types_test.go
@@ -0,0 +1,70 @@
+package freedesktop
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAccountsState_Struct(t *testing.T) {
+ state := AccountsState{
+ Available: true,
+ UserPath: "/org/freedesktop/Accounts/User1000",
+ RealName: "Test User",
+ UserName: "testuser",
+ Locked: false,
+ UID: 1000,
+ }
+
+ assert.True(t, state.Available)
+ assert.Equal(t, "/org/freedesktop/Accounts/User1000", state.UserPath)
+ assert.Equal(t, "Test User", state.RealName)
+ assert.Equal(t, "testuser", state.UserName)
+ assert.Equal(t, uint64(1000), state.UID)
+ assert.False(t, state.Locked)
+}
+
+func TestSettingsState_Struct(t *testing.T) {
+ state := SettingsState{
+ Available: true,
+ ColorScheme: 1, // Dark mode
+ }
+
+ assert.True(t, state.Available)
+ assert.Equal(t, uint32(1), state.ColorScheme)
+}
+
+func TestFreedeskState_Struct(t *testing.T) {
+ state := FreedeskState{
+ Accounts: AccountsState{
+ Available: true,
+ UserName: "testuser",
+ UID: 1000,
+ },
+ Settings: SettingsState{
+ Available: true,
+ ColorScheme: 0, // Light mode
+ },
+ }
+
+ assert.True(t, state.Accounts.Available)
+ assert.Equal(t, "testuser", state.Accounts.UserName)
+ assert.True(t, state.Settings.Available)
+ assert.Equal(t, uint32(0), state.Settings.ColorScheme)
+}
+
+func TestAccountsState_DefaultValues(t *testing.T) {
+ state := AccountsState{}
+
+ assert.False(t, state.Available)
+ assert.Empty(t, state.UserPath)
+ assert.Empty(t, state.UserName)
+ assert.Equal(t, uint64(0), state.UID)
+}
+
+func TestSettingsState_DefaultValues(t *testing.T) {
+ state := SettingsState{}
+
+ assert.False(t, state.Available)
+ assert.Equal(t, uint32(0), state.ColorScheme)
+}
diff --git a/backend/internal/server/loginctl/actions.go b/backend/internal/server/loginctl/actions.go
new file mode 100644
index 00000000..858248e9
--- /dev/null
+++ b/backend/internal/server/loginctl/actions.go
@@ -0,0 +1,88 @@
+package loginctl
+
+import (
+ "fmt"
+)
+
+func (m *Manager) Lock() error {
+ if m.sessionObj == nil {
+ return fmt.Errorf("session object not available")
+ }
+ err := m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to lock session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Unlock() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to unlock session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Activate() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to activate session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) SetIdleHint(idle bool) error {
+ err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to set idle hint: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) Terminate() error {
+ err := m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err
+ if err != nil {
+ if refreshErr := m.refreshSessionBinding(); refreshErr == nil {
+ err = m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err
+ }
+ if err != nil {
+ return fmt.Errorf("failed to terminate session: %w", err)
+ }
+ }
+ return nil
+}
+
+func (m *Manager) SetLockBeforeSuspend(enabled bool) {
+ m.lockBeforeSuspend.Store(enabled)
+}
+
+func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
+ m.sleepInhibitorEnabled.Store(enabled)
+ if enabled {
+ // Re-acquire inhibitor if enabled
+ m.acquireSleepInhibitor()
+ } else {
+ // Release inhibitor if disabled
+ m.releaseSleepInhibitor()
+ }
+}
diff --git a/backend/internal/server/loginctl/constants.go b/backend/internal/server/loginctl/constants.go
new file mode 100644
index 00000000..f93f8d69
--- /dev/null
+++ b/backend/internal/server/loginctl/constants.go
@@ -0,0 +1,9 @@
+package loginctl
+
+const (
+ dbusDest = "org.freedesktop.login1"
+ dbusPath = "/org/freedesktop/login1"
+ dbusManagerInterface = "org.freedesktop.login1.Manager"
+ dbusSessionInterface = "org.freedesktop.login1.Session"
+ dbusPropsInterface = "org.freedesktop.DBus.Properties"
+)
diff --git a/backend/internal/server/loginctl/handlers.go b/backend/internal/server/loginctl/handlers.go
new file mode 100644
index 00000000..3e58b663
--- /dev/null
+++ b/backend/internal/server/loginctl/handlers.go
@@ -0,0 +1,167 @@
+package loginctl
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ switch req.Method {
+ case "loginctl.getState":
+ handleGetState(conn, req, manager)
+ case "loginctl.lock":
+ handleLock(conn, req, manager)
+ case "loginctl.unlock":
+ handleUnlock(conn, req, manager)
+ case "loginctl.activate":
+ handleActivate(conn, req, manager)
+ case "loginctl.setIdleHint":
+ handleSetIdleHint(conn, req, manager)
+ case "loginctl.setLockBeforeSuspend":
+ handleSetLockBeforeSuspend(conn, req, manager)
+ case "loginctl.setSleepInhibitorEnabled":
+ handleSetSleepInhibitorEnabled(conn, req, manager)
+ case "loginctl.lockerReady":
+ handleLockerReady(conn, req, manager)
+ case "loginctl.terminate":
+ handleTerminate(conn, req, manager)
+ case "loginctl.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleLock(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.Lock(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "locked"})
+}
+
+func handleUnlock(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.Unlock(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "unlocked"})
+}
+
+func handleActivate(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.Activate(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "activated"})
+}
+
+func handleSetIdleHint(conn net.Conn, req Request, manager *Manager) {
+ idle, ok := req.Params["idle"].(bool)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'idle' parameter")
+ return
+ }
+
+ if err := manager.SetIdleHint(idle); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "idle hint set"})
+}
+
+func handleSetLockBeforeSuspend(conn net.Conn, req Request, manager *Manager) {
+ enabled, ok := req.Params["enabled"].(bool)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'enabled' parameter")
+ return
+ }
+
+ manager.SetLockBeforeSuspend(enabled)
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "lock before suspend set"})
+}
+
+func handleSetSleepInhibitorEnabled(conn net.Conn, req Request, manager *Manager) {
+ enabled, ok := req.Params["enabled"].(bool)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'enabled' parameter")
+ return
+ }
+
+ manager.SetSleepInhibitorEnabled(enabled)
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
+}
+
+func handleLockerReady(conn net.Conn, req Request, manager *Manager) {
+ manager.lockTimerMu.Lock()
+ if manager.lockTimer != nil {
+ manager.lockTimer.Stop()
+ manager.lockTimer = nil
+ }
+ manager.lockTimerMu.Unlock()
+
+ id := manager.sleepCycleID.Load()
+ manager.releaseForCycle(id)
+
+ if manager.inSleepCycle.Load() {
+ manager.signalLockerReady()
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "ok"})
+}
+
+func handleTerminate(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.Terminate(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "terminated"})
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := SessionEvent{
+ Type: EventStateChanged,
+ Data: initialState,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := SessionEvent{
+ Type: EventStateChanged,
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/backend/internal/server/loginctl/handlers_test.go b/backend/internal/server/loginctl/handlers_test.go
new file mode 100644
index 00000000..49a81ef5
--- /dev/null
+++ b/backend/internal/server/loginctl/handlers_test.go
@@ -0,0 +1,502 @@
+package loginctl
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ mockdbus "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/github.com/godbus/dbus/v5"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func TestRespondError_Loginctl(t *testing.T) {
+ conn := newMockNetConn()
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond_Loginctl(t *testing.T) {
+ conn := newMockNetConn()
+ result := SuccessResult{Success: true, Message: "test"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "test", resp.Result.Message)
+}
+
+func TestHandleGetState(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ SessionType: "wayland",
+ SessionClass: "user",
+ UserName: "testuser",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.getState"}
+
+ handleGetState(conn, req, manager)
+
+ var resp models.Response[SessionState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, "1", resp.Result.SessionID)
+ assert.False(t, resp.Result.Locked)
+ assert.True(t, resp.Result.Active)
+}
+
+func TestHandleLock(t *testing.T) {
+ t.Run("successful lock", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.lock"}
+ handleLock(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "locked", resp.Result.Message)
+ })
+
+ t.Run("lock fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.lock"}
+ handleLock(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to lock session")
+ })
+}
+
+func TestHandleUnlock(t *testing.T) {
+ t.Run("successful unlock", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.unlock"}
+ handleUnlock(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "unlocked", resp.Result.Message)
+ })
+
+ t.Run("unlock fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.unlock"}
+ handleUnlock(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to unlock session")
+ })
+}
+
+func TestHandleActivate(t *testing.T) {
+ t.Run("successful activate", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.activate"}
+ handleActivate(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "activated", resp.Result.Message)
+ })
+
+ t.Run("activate fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.activate"}
+ handleActivate(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to activate session")
+ })
+}
+
+func TestHandleSetIdleHint(t *testing.T) {
+ t.Run("missing idle parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]interface{}{},
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'idle' parameter")
+ })
+
+ t.Run("successful set idle hint true", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), true).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]interface{}{
+ "idle": true,
+ },
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "idle hint set", resp.Result.Message)
+ })
+
+ t.Run("set idle hint fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), false).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "loginctl.setIdleHint",
+ Params: map[string]interface{}{
+ "idle": false,
+ },
+ }
+
+ handleSetIdleHint(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to set idle hint")
+ })
+}
+
+func TestHandleTerminate(t *testing.T) {
+ t.Run("successful terminate", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.terminate"}
+ handleTerminate(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "terminated", resp.Result.Message)
+ })
+
+ t.Run("terminate fails", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: assert.AnError}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall)
+
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ sessionObj: mockSessionObj,
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.terminate"}
+ handleTerminate(conn, req, manager)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "failed to terminate session")
+ })
+}
+
+func TestHandleRequest(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ t.Run("unknown method", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "loginctl.unknown",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+
+ t.Run("valid method - getState", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "loginctl.getState",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[SessionState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ })
+
+ t.Run("lock method", func(t *testing.T) {
+ mockSessionObj := mockdbus.NewMockBusObject(t)
+ mockCall := &dbus.Call{Err: nil}
+ mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", mock.Anything).Return(mockCall)
+
+ manager.sessionObj = mockSessionObj
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "loginctl.lock",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ })
+}
+
+func TestHandleSubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "loginctl.subscribe"}
+
+ done := make(chan bool)
+ go func() {
+ handleSubscribe(conn, req, manager)
+ done <- true
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+
+ conn.Close()
+
+ if conn.writeBuf.Len() > 0 {
+ var resp models.Response[SessionEvent]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ if err == nil {
+ assert.Equal(t, 123, resp.ID)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, EventStateChanged, resp.Result.Type)
+ assert.Equal(t, "1", resp.Result.Data.SessionID)
+ }
+ }
+
+ select {
+ case <-done:
+ case <-time.After(100 * time.Millisecond):
+ }
+}
diff --git a/backend/internal/server/loginctl/manager.go b/backend/internal/server/loginctl/manager.go
new file mode 100644
index 00000000..58b091cb
--- /dev/null
+++ b/backend/internal/server/loginctl/manager.go
@@ -0,0 +1,597 @@
+package loginctl
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func NewManager() (*Manager, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ sessionID := os.Getenv("XDG_SESSION_ID")
+ if sessionID == "" {
+ sessionID = "self"
+ }
+
+ m := &Manager{
+ state: &SessionState{
+ SessionID: sessionID,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ conn: conn,
+ dirty: make(chan struct{}, 1),
+ signals: make(chan *dbus.Signal, 256),
+ }
+ m.sleepInhibitorEnabled.Store(true)
+
+ if err := m.initialize(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ if err := m.acquireSleepInhibitor(); err != nil {
+ fmt.Fprintf(os.Stderr, "sleep inhibitor unavailable: %v\n", err)
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ if err := m.startSignalPump(); err != nil {
+ m.Close()
+ return nil, err
+ }
+
+ return m, nil
+}
+
+func (m *Manager) initialize() error {
+ m.managerObj = m.conn.Object(dbusDest, dbus.ObjectPath(dbusPath))
+
+ m.initializeFallbackDelay()
+
+ sessionPath, err := m.getSession(m.state.SessionID)
+ if err != nil {
+ return fmt.Errorf("failed to get session path: %w", err)
+ }
+
+ m.stateMutex.Lock()
+ m.state.SessionPath = string(sessionPath)
+ m.sessionPath = sessionPath
+ m.stateMutex.Unlock()
+
+ m.sessionObj = m.conn.Object(dbusDest, sessionPath)
+
+ if err := m.updateSessionState(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
+ var out dbus.ObjectPath
+ err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out)
+ if err != nil {
+ return "", err
+ }
+ return out, nil
+}
+
+func (m *Manager) refreshSessionBinding() error {
+ if m.managerObj == nil || m.conn == nil {
+ return fmt.Errorf("manager not fully initialized")
+ }
+
+ sessionPath, err := m.getSession(m.state.SessionID)
+ if err != nil {
+ return fmt.Errorf("failed to get session path: %w", err)
+ }
+
+ m.stateMutex.RLock()
+ currentPath := m.sessionPath
+ m.stateMutex.RUnlock()
+
+ if sessionPath == currentPath {
+ return nil
+ }
+
+ m.stopSignalPump()
+
+ m.stateMutex.Lock()
+ m.state.SessionPath = string(sessionPath)
+ m.sessionPath = sessionPath
+ m.stateMutex.Unlock()
+
+ m.sessionObj = m.conn.Object(dbusDest, sessionPath)
+
+ if err := m.updateSessionState(); err != nil {
+ return err
+ }
+
+ m.signals = make(chan *dbus.Signal, 256)
+ return m.startSignalPump()
+}
+
+func (m *Manager) updateSessionState() error {
+ ctx := context.Background()
+ props, err := m.getSessionProperties(ctx)
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ defer m.stateMutex.Unlock()
+
+ if v, ok := props["Active"]; ok {
+ if val, ok := v.Value().(bool); ok {
+ m.state.Active = val
+ }
+ }
+ if v, ok := props["IdleHint"]; ok {
+ if val, ok := v.Value().(bool); ok {
+ m.state.IdleHint = val
+ }
+ }
+ if v, ok := props["IdleSinceHint"]; ok {
+ if val, ok := v.Value().(uint64); ok {
+ m.state.IdleSinceHint = val
+ }
+ }
+ if v, ok := props["LockedHint"]; ok {
+ if val, ok := v.Value().(bool); ok {
+ m.state.LockedHint = val
+ m.state.Locked = val
+ }
+ }
+ if v, ok := props["Type"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.SessionType = val
+ }
+ }
+ if v, ok := props["Class"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.SessionClass = val
+ }
+ }
+ if v, ok := props["User"]; ok {
+ if userArr, ok := v.Value().([]interface{}); ok && len(userArr) >= 1 {
+ if uid, ok := userArr[0].(uint32); ok {
+ m.state.User = uid
+ }
+ }
+ }
+ if v, ok := props["Name"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.UserName = val
+ }
+ }
+ if v, ok := props["RemoteHost"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.RemoteHost = val
+ }
+ }
+ if v, ok := props["Service"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Service = val
+ }
+ }
+ if v, ok := props["TTY"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.TTY = val
+ }
+ }
+ if v, ok := props["Display"]; ok {
+ if val, ok := v.Value().(string); ok {
+ m.state.Display = val
+ }
+ }
+ if v, ok := props["Remote"]; ok {
+ if val, ok := v.Value().(bool); ok {
+ m.state.Remote = val
+ }
+ }
+ if v, ok := props["Seat"]; ok {
+ if seatArr, ok := v.Value().([]interface{}); ok && len(seatArr) >= 1 {
+ if seatID, ok := seatArr[0].(string); ok {
+ m.state.Seat = seatID
+ }
+ }
+ }
+ if v, ok := props["VTNr"]; ok {
+ if val, ok := v.Value().(uint32); ok {
+ m.state.VTNr = val
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) getSessionProperties(ctx context.Context) (map[string]dbus.Variant, error) {
+ var props map[string]dbus.Variant
+ err := m.sessionObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props)
+ if err != nil {
+ return nil, err
+ }
+ return props, nil
+}
+
+func (m *Manager) acquireSleepInhibitor() error {
+ if !m.sleepInhibitorEnabled.Load() {
+ return nil
+ }
+
+ m.inhibitMu.Lock()
+ defer m.inhibitMu.Unlock()
+
+ if m.inhibitFile != nil {
+ return nil
+ }
+
+ if m.managerObj == nil {
+ return fmt.Errorf("manager object not available")
+ }
+
+ file, err := m.inhibit("sleep", "DankMaterialShell", "Lock before suspend", "delay")
+ if err != nil {
+ return err
+ }
+
+ m.inhibitFile = file
+ return nil
+}
+
+func (m *Manager) inhibit(what, who, why, mode string) (*os.File, error) {
+ var fd dbus.UnixFD
+ err := m.managerObj.Call(dbusManagerInterface+".Inhibit", 0, what, who, why, mode).Store(&fd)
+ if err != nil {
+ return nil, err
+ }
+ return os.NewFile(uintptr(fd), "inhibit"), nil
+}
+
+func (m *Manager) releaseSleepInhibitor() {
+ m.inhibitMu.Lock()
+ f := m.inhibitFile
+ m.inhibitFile = nil
+ m.inhibitMu.Unlock()
+ if f != nil {
+ f.Close()
+ }
+}
+
+func (m *Manager) releaseForCycle(id uint64) {
+ if !m.inSleepCycle.Load() || m.sleepCycleID.Load() != id {
+ return
+ }
+ m.releaseSleepInhibitor()
+}
+
+func (m *Manager) initializeFallbackDelay() {
+ var maxDelayUSec uint64
+ err := m.managerObj.Call(
+ dbusPropsInterface+".Get",
+ 0,
+ dbusManagerInterface,
+ "InhibitDelayMaxUSec",
+ ).Store(&maxDelayUSec)
+
+ if err != nil {
+ m.fallbackDelay = 2 * time.Second
+ return
+ }
+
+ maxDelay := time.Duration(maxDelayUSec) * time.Microsecond
+ computed := (maxDelay * 8) / 10
+
+ if computed < 2*time.Second {
+ m.fallbackDelay = 2 * time.Second
+ } else if computed > 4*time.Second {
+ m.fallbackDelay = 4 * time.Second
+ } else {
+ m.fallbackDelay = computed
+ }
+}
+
+func (m *Manager) newLockerReadyCh() chan struct{} {
+ m.lockerReadyChMu.Lock()
+ defer m.lockerReadyChMu.Unlock()
+ m.lockerReadyCh = make(chan struct{})
+ return m.lockerReadyCh
+}
+
+func (m *Manager) signalLockerReady() {
+ m.lockerReadyChMu.Lock()
+ ch := m.lockerReadyCh
+ if ch != nil {
+ close(ch)
+ m.lockerReadyCh = nil
+ }
+ m.lockerReadyChMu.Unlock()
+}
+
+func (m *Manager) snapshotState() SessionState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return *m.state
+}
+
+func stateChangedMeaningfully(old, new *SessionState) bool {
+ if old.Locked != new.Locked {
+ return true
+ }
+ if old.LockedHint != new.LockedHint {
+ return true
+ }
+ if old.Active != new.Active {
+ return true
+ }
+ if old.IdleHint != new.IdleHint {
+ return true
+ }
+ if old.PreparingForSleep != new.PreparingForSleep {
+ return true
+ }
+ return false
+}
+
+func (m *Manager) GetState() SessionState {
+ return m.snapshotState()
+}
+
+func (m *Manager) Subscribe(id string) chan SessionState {
+ ch := make(chan SessionState, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.subMutex.RLock()
+ if len(m.subscribers) == 0 {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChangedMeaningfully(m.lastNotifiedState, ¤tState) {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ }
+ m.subMutex.RUnlock()
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) startSignalPump() error {
+ m.conn.Signal(m.signals)
+
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+
+ if err := m.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
+ dbus.WithMatchInterface("org.freedesktop.DBus"),
+ dbus.WithMatchMember("NameOwnerChanged"),
+ ); err != nil {
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ )
+ m.conn.RemoveSignal(m.signals)
+ return err
+ }
+
+ m.sigWG.Add(1)
+ go func() {
+ defer m.sigWG.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig, ok := <-m.signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ m.handleDBusSignal(sig)
+ }
+ }
+ }()
+ return nil
+}
+
+func (m *Manager) stopSignalPump() {
+ if m.conn == nil {
+ return
+ }
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Lock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(m.sessionPath),
+ dbus.WithMatchInterface(dbusSessionInterface),
+ dbus.WithMatchMember("Unlock"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)),
+ dbus.WithMatchInterface(dbusManagerInterface),
+ dbus.WithMatchMember("PrepareForSleep"),
+ )
+ m.conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
+ dbus.WithMatchInterface("org.freedesktop.DBus"),
+ dbus.WithMatchMember("NameOwnerChanged"),
+ )
+
+ m.conn.RemoveSignal(m.signals)
+ close(m.signals)
+
+ m.sigWG.Wait()
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+
+ m.stopSignalPump()
+
+ m.releaseSleepInhibitor()
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan SessionState)
+ m.subMutex.Unlock()
+
+ if m.conn != nil {
+ m.conn.Close()
+ }
+}
diff --git a/backend/internal/server/loginctl/manager_test.go b/backend/internal/server/loginctl/manager_test.go
new file mode 100644
index 00000000..96006668
--- /dev/null
+++ b/backend/internal/server/loginctl/manager_test.go
@@ -0,0 +1,313 @@
+package loginctl
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetState(t *testing.T) {
+ state := &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ IdleHint: false,
+ SessionType: "wayland",
+ SessionClass: "user",
+ UserName: "testuser",
+ }
+
+ manager := &Manager{
+ state: state,
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.Equal(t, "1", result.SessionID)
+ assert.False(t, result.Locked)
+ assert.True(t, result.Active)
+ assert.Equal(t, "wayland", result.SessionType)
+ assert.Equal(t, "testuser", result.UserName)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ }
+
+ ch := manager.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ assert.Equal(t, 64, cap(ch))
+
+ manager.subMutex.RLock()
+ _, exists := manager.subscribers["test-client"]
+ manager.subMutex.RUnlock()
+ assert.True(t, exists)
+}
+
+func TestManager_Unsubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ }
+
+ ch := manager.Subscribe("test-client")
+
+ manager.Unsubscribe("test-client")
+
+ _, ok := <-ch
+ assert.False(t, ok)
+
+ manager.subMutex.RLock()
+ _, exists := manager.subscribers["test-client"]
+ manager.subMutex.RUnlock()
+ assert.False(t, exists)
+}
+
+func TestManager_Unsubscribe_NonExistent(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ }
+
+ // Unsubscribe a non-existent client should not panic
+ assert.NotPanics(t, func() {
+ manager.Unsubscribe("non-existent")
+ })
+}
+
+func TestManager_NotifySubscribers(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan SessionState, 10)
+ manager.subMutex.Lock()
+ manager.subscribers["test-client"] = ch
+ manager.subMutex.Unlock()
+
+ manager.notifySubscribers()
+
+ select {
+ case state := <-ch:
+ assert.Equal(t, "1", state.SessionID)
+ assert.False(t, state.Locked)
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("did not receive state update")
+ }
+
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+}
+
+func TestManager_NotifySubscribers_Debounce(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan SessionState, 10)
+ manager.subMutex.Lock()
+ manager.subscribers["test-client"] = ch
+ manager.subMutex.Unlock()
+
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+
+ receivedCount := 0
+ timeout := time.After(200 * time.Millisecond)
+ for {
+ select {
+ case <-ch:
+ receivedCount++
+ case <-timeout:
+ assert.Equal(t, 1, receivedCount, "should receive exactly one debounced update")
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+ return
+ }
+ }
+}
+
+func TestManager_Close(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ }
+
+ ch1 := make(chan SessionState, 1)
+ ch2 := make(chan SessionState, 1)
+ manager.subMutex.Lock()
+ manager.subscribers["client1"] = ch1
+ manager.subscribers["client2"] = ch2
+ manager.subMutex.Unlock()
+
+ manager.Close()
+
+ select {
+ case <-manager.stopChan:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("stopChan not closed")
+ }
+
+ _, ok1 := <-ch1
+ _, ok2 := <-ch2
+ assert.False(t, ok1, "ch1 should be closed")
+ assert.False(t, ok2, "ch2 should be closed")
+
+ assert.Len(t, manager.subscribers, 0)
+}
+
+func TestManager_GetState_ThreadSafe(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ done := make(chan bool)
+ for i := 0; i < 10; i++ {
+ go func() {
+ state := manager.GetState()
+ assert.Equal(t, "1", state.SessionID)
+ assert.True(t, state.Active)
+ done <- true
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ select {
+ case <-done:
+ case <-time.After(1 * time.Second):
+ t.Fatal("timeout waiting for goroutines")
+ }
+ }
+}
+
+func TestStateChangedMeaningfully(t *testing.T) {
+ tests := []struct {
+ name string
+ old *SessionState
+ new *SessionState
+ expected bool
+ }{
+ {
+ name: "no change",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: true, IdleHint: false},
+ expected: false,
+ },
+ {
+ name: "locked changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: true, Active: true, IdleHint: false},
+ expected: true,
+ },
+ {
+ name: "active changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: false, IdleHint: false},
+ expected: true,
+ },
+ {
+ name: "idle hint changed",
+ old: &SessionState{Locked: false, Active: true, IdleHint: false},
+ new: &SessionState{Locked: false, Active: true, IdleHint: true},
+ expected: true,
+ },
+ {
+ name: "locked hint changed",
+ old: &SessionState{Locked: false, Active: true, LockedHint: false},
+ new: &SessionState{Locked: false, Active: true, LockedHint: true},
+ expected: true,
+ },
+ {
+ name: "preparing for sleep changed",
+ old: &SessionState{Locked: false, Active: true, PreparingForSleep: false},
+ new: &SessionState{Locked: false, Active: true, PreparingForSleep: true},
+ expected: true,
+ },
+ {
+ name: "non-meaningful change (username)",
+ old: &SessionState{Locked: false, Active: true, UserName: "user1"},
+ new: &SessionState{Locked: false, Active: true, UserName: "user2"},
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := stateChangedMeaningfully(tt.old, tt.new)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestManager_SnapshotState(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ SessionID: "1",
+ Locked: false,
+ Active: true,
+ UserName: "testuser",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ snapshot := manager.snapshotState()
+ assert.Equal(t, "1", snapshot.SessionID)
+ assert.False(t, snapshot.Locked)
+ assert.True(t, snapshot.Active)
+ assert.Equal(t, "testuser", snapshot.UserName)
+
+ snapshot.Locked = true
+ assert.False(t, manager.state.Locked)
+}
+
+func TestNewManager(t *testing.T) {
+ t.Run("attempts to create manager", func(t *testing.T) {
+ manager, err := NewManager()
+ if err != nil {
+ assert.Nil(t, manager)
+ } else {
+ assert.NotNil(t, manager)
+ assert.NotNil(t, manager.state)
+ assert.NotNil(t, manager.subscribers)
+ assert.NotNil(t, manager.stopChan)
+
+ manager.Close()
+ }
+ })
+}
diff --git a/backend/internal/server/loginctl/monitor.go b/backend/internal/server/loginctl/monitor.go
new file mode 100644
index 00000000..c4301050
--- /dev/null
+++ b/backend/internal/server/loginctl/monitor.go
@@ -0,0 +1,157 @@
+package loginctl
+
+import (
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
+ switch sig.Name {
+ case dbusSessionInterface + ".Lock":
+ m.stateMutex.Lock()
+ m.state.Locked = true
+ m.state.LockedHint = true
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ if m.sleepInhibitorEnabled.Load() && m.inSleepCycle.Load() {
+ id := m.sleepCycleID.Load()
+ m.lockTimerMu.Lock()
+ if m.lockTimer != nil {
+ m.lockTimer.Stop()
+ }
+ m.lockTimer = time.AfterFunc(m.fallbackDelay, func() {
+ m.releaseForCycle(id)
+ })
+ m.lockTimerMu.Unlock()
+ }
+
+ case dbusSessionInterface + ".Unlock":
+ m.stateMutex.Lock()
+ m.state.Locked = false
+ m.state.LockedHint = false
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ // Cancel the lock timer if it's still running
+ m.lockTimerMu.Lock()
+ if m.lockTimer != nil {
+ m.lockTimer.Stop()
+ m.lockTimer = nil
+ }
+ m.lockTimerMu.Unlock()
+
+ // Re-acquire the sleep inhibitor (acquireSleepInhibitor checks the enabled flag)
+ m.acquireSleepInhibitor()
+
+ case dbusManagerInterface + ".PrepareForSleep":
+ if len(sig.Body) == 0 {
+ return
+ }
+ preparing, _ := sig.Body[0].(bool)
+
+ if preparing {
+ cycleID := m.sleepCycleID.Add(1)
+ m.inSleepCycle.Store(true)
+
+ if m.lockBeforeSuspend.Load() {
+ m.Lock()
+ }
+
+ readyCh := m.newLockerReadyCh()
+ go func(id uint64, ch <-chan struct{}) {
+ <-ch
+ if m.inSleepCycle.Load() && m.sleepCycleID.Load() == id {
+ m.releaseSleepInhibitor()
+ }
+ }(cycleID, readyCh)
+ } else {
+ m.inSleepCycle.Store(false)
+ m.signalLockerReady()
+ m.refreshSessionBinding()
+ m.acquireSleepInhibitor()
+ }
+
+ m.stateMutex.Lock()
+ m.state.PreparingForSleep = preparing
+ m.stateMutex.Unlock()
+ m.notifySubscribers()
+
+ case dbusPropsInterface + ".PropertiesChanged":
+ m.handlePropertiesChanged(sig)
+
+ case "org.freedesktop.DBus.NameOwnerChanged":
+ if len(sig.Body) == 3 {
+ name, _ := sig.Body[0].(string)
+ oldOwner, _ := sig.Body[1].(string)
+ newOwner, _ := sig.Body[2].(string)
+ if name == dbusDest && oldOwner != "" && newOwner != "" {
+ m.updateSessionState()
+ if !m.inSleepCycle.Load() {
+ m.acquireSleepInhibitor()
+ }
+ m.notifySubscribers()
+ }
+ }
+ }
+}
+
+func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) {
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok || iface != dbusSessionInterface {
+ return
+ }
+
+ changes, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ var needsUpdate bool
+
+ for key, variant := range changes {
+ switch key {
+ case "Active":
+ if val, ok := variant.Value().(bool); ok {
+ m.stateMutex.Lock()
+ m.state.Active = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+
+ case "IdleHint":
+ if val, ok := variant.Value().(bool); ok {
+ m.stateMutex.Lock()
+ m.state.IdleHint = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+
+ case "IdleSinceHint":
+ if val, ok := variant.Value().(uint64); ok {
+ m.stateMutex.Lock()
+ m.state.IdleSinceHint = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+
+ case "LockedHint":
+ if val, ok := variant.Value().(bool); ok {
+ m.stateMutex.Lock()
+ m.state.LockedHint = val
+ m.state.Locked = val
+ m.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ }
+ }
+
+ if needsUpdate {
+ m.notifySubscribers()
+ }
+}
diff --git a/backend/internal/server/loginctl/monitor_test.go b/backend/internal/server/loginctl/monitor_test.go
new file mode 100644
index 00000000..25caa0b3
--- /dev/null
+++ b/backend/internal/server/loginctl/monitor_test.go
@@ -0,0 +1,322 @@
+package loginctl
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_HandleDBusSignal_Lock(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Locked: false,
+ LockedHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Session.Lock",
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Locked)
+ assert.True(t, manager.state.LockedHint)
+}
+
+func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Locked: true,
+ LockedHint: true,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Session.Unlock",
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.Locked)
+ assert.False(t, manager.state.LockedHint)
+}
+
+func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
+ t.Run("preparing for sleep - true", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []interface{}{true},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.PreparingForSleep)
+ })
+
+ t.Run("preparing for sleep - false", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: true,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []interface{}{false},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.PreparingForSleep)
+ })
+
+ t.Run("empty body", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ PreparingForSleep: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.login1.Manager.PrepareForSleep",
+ Body: []interface{}{},
+ }
+
+ manager.handleDBusSignal(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.PreparingForSleep)
+ })
+}
+
+func TestManager_HandlePropertiesChanged(t *testing.T) {
+ t.Run("active property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Active)
+ })
+
+ t.Run("idle hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ IdleHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "IdleHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.IdleHint)
+ })
+
+ t.Run("idle since hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ IdleSinceHint: 0,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "IdleSinceHint": dbus.MakeVariant(uint64(123456789)),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.Equal(t, uint64(123456789), manager.state.IdleSinceHint)
+ })
+
+ t.Run("locked hint property changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ LockedHint: false,
+ Locked: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "LockedHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.LockedHint)
+ assert.True(t, manager.state.Locked)
+ })
+
+ t.Run("wrong interface", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{
+ "org.freedesktop.SomeOtherInterface",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.False(t, manager.state.Active)
+ })
+
+ t.Run("empty body", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{},
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{},
+ }
+
+ assert.NotPanics(t, func() {
+ manager.handlePropertiesChanged(sig)
+ })
+ })
+
+ t.Run("multiple properties changed", func(t *testing.T) {
+ manager := &Manager{
+ state: &SessionState{
+ Active: false,
+ IdleHint: false,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan SessionState),
+ subMutex: sync.RWMutex{},
+ dirty: make(chan struct{}, 1),
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{
+ "org.freedesktop.login1.Session",
+ map[string]dbus.Variant{
+ "Active": dbus.MakeVariant(true),
+ "IdleHint": dbus.MakeVariant(true),
+ },
+ },
+ }
+
+ manager.handlePropertiesChanged(sig)
+
+ manager.stateMutex.RLock()
+ defer manager.stateMutex.RUnlock()
+ assert.True(t, manager.state.Active)
+ assert.True(t, manager.state.IdleHint)
+ })
+}
diff --git a/backend/internal/server/loginctl/types.go b/backend/internal/server/loginctl/types.go
new file mode 100644
index 00000000..559396bf
--- /dev/null
+++ b/backend/internal/server/loginctl/types.go
@@ -0,0 +1,76 @@
+package loginctl
+
+import (
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+type SessionState struct {
+ SessionID string `json:"sessionId"`
+ SessionPath string `json:"sessionPath"`
+ Locked bool `json:"locked"`
+ Active bool `json:"active"`
+ IdleHint bool `json:"idleHint"`
+ IdleSinceHint uint64 `json:"idleSinceHint"`
+ LockedHint bool `json:"lockedHint"`
+ SessionType string `json:"sessionType"`
+ SessionClass string `json:"sessionClass"`
+ User uint32 `json:"user"`
+ UserName string `json:"userName"`
+ RemoteHost string `json:"remoteHost"`
+ Service string `json:"service"`
+ TTY string `json:"tty"`
+ Display string `json:"display"`
+ Remote bool `json:"remote"`
+ Seat string `json:"seat"`
+ VTNr uint32 `json:"vtnr"`
+ PreparingForSleep bool `json:"preparingForSleep"`
+}
+
+type EventType string
+
+const (
+ EventStateChanged EventType = "state_changed"
+ EventLock EventType = "lock"
+ EventUnlock EventType = "unlock"
+ EventPrepareForSleep EventType = "prepare_for_sleep"
+ EventIdleHintChanged EventType = "idle_hint_changed"
+ EventLockedHintChanged EventType = "locked_hint_changed"
+)
+
+type SessionEvent struct {
+ Type EventType `json:"type"`
+ Data SessionState `json:"data"`
+}
+
+type Manager struct {
+ state *SessionState
+ stateMutex sync.RWMutex
+ subscribers map[string]chan SessionState
+ subMutex sync.RWMutex
+ stopChan chan struct{}
+ conn *dbus.Conn
+ sessionPath dbus.ObjectPath
+ managerObj dbus.BusObject
+ sessionObj dbus.BusObject
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *SessionState
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ inhibitMu sync.Mutex
+ inhibitFile *os.File
+ lockBeforeSuspend atomic.Bool
+ inSleepCycle atomic.Bool
+ sleepCycleID atomic.Uint64
+ lockerReadyChMu sync.Mutex
+ lockerReadyCh chan struct{}
+ lockTimerMu sync.Mutex
+ lockTimer *time.Timer
+ sleepInhibitorEnabled atomic.Bool
+ fallbackDelay time.Duration
+}
diff --git a/backend/internal/server/loginctl/types_test.go b/backend/internal/server/loginctl/types_test.go
new file mode 100644
index 00000000..d5063ea4
--- /dev/null
+++ b/backend/internal/server/loginctl/types_test.go
@@ -0,0 +1,63 @@
+package loginctl
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEventType_Constants(t *testing.T) {
+ assert.Equal(t, EventType("state_changed"), EventStateChanged)
+ assert.Equal(t, EventType("lock"), EventLock)
+ assert.Equal(t, EventType("unlock"), EventUnlock)
+ assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep)
+ assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged)
+ assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged)
+}
+
+func TestSessionState_Struct(t *testing.T) {
+ state := SessionState{
+ SessionID: "1",
+ SessionPath: "/org/freedesktop/login1/session/_31",
+ Locked: false,
+ Active: true,
+ IdleHint: false,
+ IdleSinceHint: 0,
+ LockedHint: false,
+ SessionType: "wayland",
+ SessionClass: "user",
+ User: 1000,
+ UserName: "testuser",
+ RemoteHost: "",
+ Service: "gdm-password",
+ TTY: "tty2",
+ Display: ":1",
+ Remote: false,
+ Seat: "seat0",
+ VTNr: 2,
+ PreparingForSleep: false,
+ }
+
+ assert.Equal(t, "1", state.SessionID)
+ assert.True(t, state.Active)
+ assert.False(t, state.Locked)
+ assert.Equal(t, "wayland", state.SessionType)
+ assert.Equal(t, uint32(1000), state.User)
+ assert.Equal(t, "testuser", state.UserName)
+}
+
+func TestSessionEvent_Struct(t *testing.T) {
+ state := SessionState{
+ SessionID: "1",
+ Locked: true,
+ }
+
+ event := SessionEvent{
+ Type: EventLock,
+ Data: state,
+ }
+
+ assert.Equal(t, EventLock, event.Type)
+ assert.Equal(t, "1", event.Data.SessionID)
+ assert.True(t, event.Data.Locked)
+}
diff --git a/backend/internal/server/models/types.go b/backend/internal/server/models/types.go
new file mode 100644
index 00000000..ebeaa1f7
--- /dev/null
+++ b/backend/internal/server/models/types.go
@@ -0,0 +1,31 @@
+package models
+
+import (
+ "encoding/json"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type Response[T any] struct {
+ ID int `json:"id,omitempty"`
+ Result *T `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func RespondError(conn net.Conn, id int, errMsg string) {
+ log.Errorf("DMS API Error: id=%d error=%s", id, errMsg)
+ resp := Response[any]{ID: id, Error: errMsg}
+ json.NewEncoder(conn).Encode(resp)
+}
+
+func Respond[T any](conn net.Conn, id int, result T) {
+ resp := Response[T]{ID: id, Result: &result}
+ json.NewEncoder(conn).Encode(resp)
+}
diff --git a/backend/internal/server/network/API.md b/backend/internal/server/network/API.md
new file mode 100644
index 00000000..0163389f
--- /dev/null
+++ b/backend/internal/server/network/API.md
@@ -0,0 +1,552 @@
+# NetworkManager API Documentation
+
+## Overview
+
+The network manager API provides methods for managing WiFi connections, monitoring network state, and handling credential prompts through NetworkManager. Communication occurs over a message-based protocol (websocket, IPC, etc.) with event subscriptions for state updates.
+
+## API Methods
+
+### network.wifi.connect
+
+Initiate a WiFi connection.
+
+**Request:**
+```json
+{
+ "method": "network.wifi.connect",
+ "params": {
+ "ssid": "NetworkName",
+ "password": "optional-password",
+ "interactive": true
+ }
+}
+```
+
+**Parameters:**
+- `ssid` (string, required): Network SSID
+- `password` (string, optional): Pre-shared key for WPA/WPA2/WPA3 networks
+- `interactive` (boolean, optional): Enable credential prompting if authentication fails or password is missing. Automatically set to `true` when connecting to secured networks without providing a password.
+
+**Response:**
+```json
+{
+ "success": true,
+ "message": "connecting"
+}
+```
+
+**Behavior:**
+- Returns immediately; connection happens asynchronously
+- State updates delivered via `network` service subscription
+- Credential prompts delivered via `network.credentials` service subscription
+
+### network.credentials.submit
+
+Submit credentials in response to a prompt.
+
+**Request:**
+```json
+{
+ "method": "network.credentials.submit",
+ "params": {
+ "token": "correlation-token",
+ "secrets": {
+ "psk": "password"
+ },
+ "save": true
+ }
+}
+```
+
+**Parameters:**
+- `token` (string, required): Token from credential prompt
+- `secrets` (object, required): Key-value map of credential fields
+- `save` (boolean, optional): Whether to persist credentials (default: false)
+
+**Common secret fields:**
+- `psk`: Pre-shared key for WPA2/WPA3 personal networks
+- `identity`: Username for 802.1X enterprise networks
+- `password`: Password for 802.1X enterprise networks
+
+### network.credentials.cancel
+
+Cancel a credential prompt.
+
+**Request:**
+```json
+{
+ "method": "network.credentials.cancel",
+ "params": {
+ "token": "correlation-token"
+ }
+}
+```
+
+## Event Subscriptions
+
+### Subscribing to Events
+
+Subscribe to receive network state updates and credential prompts:
+
+```json
+{
+ "method": "subscribe",
+ "params": {
+ "services": ["network", "network.credentials"]
+ }
+}
+```
+
+Both services are required for full connection handling. Missing `network.credentials` means credential prompts won't be received.
+
+### network Service Events
+
+State updates are sent whenever network configuration changes:
+
+```json
+{
+ "service": "network",
+ "data": {
+ "networkStatus": "wifi",
+ "isConnecting": false,
+ "connectingSSID": "",
+ "wifiConnected": true,
+ "wifiSSID": "MyNetwork",
+ "wifiIP": "192.168.1.100",
+ "lastError": ""
+ }
+}
+```
+
+**State fields:**
+- `networkStatus`: Current connection type (`wifi`, `ethernet`, `disconnected`)
+- `isConnecting`: Whether a connection attempt is in progress
+- `connectingSSID`: SSID being connected to (empty when idle)
+- `wifiConnected`: Whether associated with an access point
+- `wifiSSID`: Currently connected network name
+- `wifiIP`: Assigned IP address (empty until DHCP completes)
+- `lastError`: Error message from last failed connection attempt
+
+### network.credentials Service Events
+
+Credential prompts are sent when authentication is required:
+
+```json
+{
+ "service": "network.credentials",
+ "data": {
+ "token": "unique-prompt-id",
+ "ssid": "NetworkName",
+ "setting": "802-11-wireless-security",
+ "fields": ["psk"],
+ "hints": ["wpa3", "sae"],
+ "reason": "Credentials required"
+ }
+}
+```
+
+**Prompt fields:**
+- `token`: Unique identifier for this prompt (use in submit/cancel)
+- `ssid`: Network requesting credentials
+- `setting`: Authentication type (`802-11-wireless-security` for personal WiFi, `802-1x` for enterprise)
+- `fields`: Array of required credential field names
+- `hints`: Additional context about the network type
+- `reason`: Human-readable explanation (e.g., "Previous password was incorrect")
+
+## Connection Flow
+
+### Typical Timeline
+
+```
+T+0ms Call network.wifi.connect
+T+10ms Receive {"success": true, "message": "connecting"}
+T+100ms State update: isConnecting=true, connectingSSID="Network"
+T+500ms Credential prompt (if needed)
+T+1000ms Submit credentials
+T+3000ms State update: wifiConnected=true, wifiIP="192.168.x.x"
+```
+
+### State Machine
+
+```
+IDLE
+ |
+ | network.wifi.connect
+ v
+CONNECTING (isConnecting=true, connectingSSID set)
+ |
+ +-- Needs credentials
+ | |
+ | v
+ | PROMPTING (credential prompt event)
+ | |
+ | | network.credentials.submit
+ | v
+ | back to CONNECTING
+ |
+ +-- Success
+ | |
+ | v
+ | CONNECTED (wifiConnected=true, wifiIP set, isConnecting=false)
+ |
+ +-- Failure
+ |
+ v
+ ERROR (isConnecting=false, !wifiConnected, lastError set)
+```
+
+## Connection Success Detection
+
+A connection is successful when all of the following are true:
+
+1. `wifiConnected` is `true`
+2. `wifiIP` is set and non-empty
+3. `wifiSSID` matches the target network
+4. `isConnecting` is `false`
+
+Do not rely on `wifiConnected` alone - the device may be associated with an access point but not have an IP address yet.
+
+**Example:**
+```javascript
+function isConnectionComplete(state, targetSSID) {
+ return state.wifiConnected &&
+ state.wifiIP &&
+ state.wifiIP !== "" &&
+ state.wifiSSID === targetSSID &&
+ !state.isConnecting;
+}
+```
+
+## Error Handling
+
+### Error Detection
+
+Errors occur when a connection attempt stops without success:
+
+```javascript
+function checkForFailure(state, wasConnecting, targetSSID) {
+ // Was connecting, now idle, but not connected
+ if (wasConnecting &&
+ !state.isConnecting &&
+ state.connectingSSID === "" &&
+ !state.wifiConnected) {
+ return state.lastError || "Connection failed";
+ }
+ return null;
+}
+```
+
+### Common Error Scenarios
+
+#### Wrong Password
+
+**Detection methods:**
+
+1. Quick failure (< 3 seconds from start)
+2. `lastError` contains "password", "auth", or "secrets"
+3. Second credential prompt with `reason: "Previous password was incorrect"`
+
+**Handling:**
+```javascript
+if (prompt.reason === "Previous password was incorrect") {
+ // Show error, clear password field, re-focus input
+}
+```
+
+#### Network Out of Range
+
+**Detection:**
+- `lastError` contains "not-found" or "connection-attempt-failed"
+
+#### Connection Timeout
+
+**Detection:**
+- `isConnecting` remains true for > 30 seconds
+
+**Implementation:**
+```javascript
+let timeout = setTimeout(() => {
+ if (currentState.isConnecting) {
+ handleTimeout();
+ }
+}, 30000);
+```
+
+#### DHCP Failure
+
+**Detection:**
+- `wifiConnected` is true
+- `wifiIP` is empty after 15+ seconds
+
+### Error Message Translation
+
+Map technical errors to user-friendly messages:
+
+| lastError value | Meaning | User message |
+|----------------|---------|--------------|
+| `secrets-required` | Password needed | "Please enter password" |
+| `authentication-failed` | Wrong password | "Incorrect password" |
+| `connection-removed` | Profile deleted | "Network configuration removed" |
+| `connection-attempt-failed` | Generic failure | "Failed to connect" |
+| `network-not-found` | Out of range | "Network not found" |
+| `(timeout)` | Timeout | "Connection timed out" |
+
+## Credential Handling
+
+### Secret Agent Architecture
+
+The credential system uses a broker pattern:
+
+```
+NetworkManager -> SecretAgent -> PromptBroker -> UI -> User
+ ^
+ |
+ User Response
+ |
+NetworkManager <- SecretAgent <- PromptBroker <- UI
+```
+
+### Implementing a Broker
+
+```go
+type CustomBroker struct {
+ ui UIInterface
+ pending map[string]chan network.PromptReply
+}
+
+func (b *CustomBroker) Ask(ctx context.Context, req network.PromptRequest) (string, error) {
+ token := generateToken()
+ b.pending[token] = make(chan network.PromptReply, 1)
+
+ // Send to UI
+ b.ui.ShowCredentialPrompt(token, req)
+
+ return token, nil
+}
+
+func (b *CustomBroker) Wait(ctx context.Context, token string) (network.PromptReply, error) {
+ select {
+ case <-ctx.Done():
+ return network.PromptReply{}, errors.New("timeout")
+ case reply := <-b.pending[token]:
+ return reply, nil
+ }
+}
+
+func (b *CustomBroker) Resolve(token string, reply network.PromptReply) error {
+ if ch, ok := b.pending[token]; ok {
+ ch <- reply
+ close(ch)
+ delete(b.pending, token)
+ }
+ return nil
+}
+```
+
+### Credential Field Types
+
+**Personal WiFi (802-11-wireless-security):**
+- Fields: `["psk"]`
+- UI: Single password input
+
+**Enterprise WiFi (802-1x):**
+- Fields: `["identity", "password"]`
+- UI: Username and password inputs
+
+### Building Secrets Object
+
+```javascript
+function buildSecrets(setting, fields, formData) {
+ let secrets = {};
+
+ if (setting === "802-11-wireless-security") {
+ secrets.psk = formData.password;
+ } else if (setting === "802-1x") {
+ secrets.identity = formData.username;
+ secrets.password = formData.password;
+ }
+
+ return secrets;
+}
+```
+
+## Best Practices
+
+### Track Target Network
+
+Always store which network you're connecting to:
+
+```javascript
+let targetSSID = null;
+
+function connect(ssid) {
+ targetSSID = ssid;
+ // send request
+}
+
+function onStateUpdate(state) {
+ if (!targetSSID) return;
+
+ if (state.wifiSSID === targetSSID && state.wifiConnected && state.wifiIP) {
+ // Success for the network we care about
+ targetSSID = null;
+ }
+}
+```
+
+### Implement Timeouts
+
+Never wait indefinitely for a connection:
+
+```javascript
+const CONNECTION_TIMEOUT = 30000; // 30 seconds
+const DHCP_TIMEOUT = 15000; // 15 seconds
+
+let timer = setTimeout(() => {
+ if (stillConnecting) {
+ handleTimeout();
+ }
+}, CONNECTION_TIMEOUT);
+```
+
+### Handle Credential Re-prompts
+
+Wrong passwords trigger a second prompt:
+
+```javascript
+function onCredentialPrompt(prompt) {
+ if (prompt.reason.includes("incorrect")) {
+ // Show error, but keep dialog open
+ showError("Wrong password");
+ clearPasswordField();
+ } else {
+ // First time prompt
+ showDialog(prompt);
+ }
+}
+```
+
+### Clean Up State
+
+Reset tracking variables on success, failure, or cancellation:
+
+```javascript
+function cleanup() {
+ clearTimeout(timer);
+ targetSSID = null;
+ closeDialogs();
+}
+```
+
+### Subscribe to Both Services
+
+Missing `network.credentials` means prompts won't arrive:
+
+```javascript
+// Correct
+services: ["network", "network.credentials"]
+
+// Wrong - will miss credential prompts
+services: ["network"]
+```
+
+## Testing
+
+### Connection Test Checklist
+
+- [ ] Connect to open network
+- [ ] Connect to WPA2 network with password provided
+- [ ] Connect to WPA2 network without password (triggers prompt)
+- [ ] Enter wrong password (verify error and re-prompt)
+- [ ] Cancel credential prompt
+- [ ] Connection timeout after 30 seconds
+- [ ] DHCP timeout detection
+- [ ] Network out of range
+- [ ] Reconnect to already-configured network
+
+### Verifying Secret Agent Setup
+
+Check connection profile flags:
+```bash
+nmcli connection show "NetworkName" | grep flags
+# Should show: 802-11-wireless-security.psk-flags: 1 (agent-owned)
+```
+
+Check agent registration in logs:
+```
+INFO: Registered with NetworkManager as secret agent
+```
+
+## Security
+
+- Never log credential values (passwords, PSKs)
+- Clear password fields when dialogs close
+- Implement prompt timeouts (default: 2 minutes)
+- Validate user input before submission
+- Use secure channels for credential transmission
+
+## Troubleshooting
+
+### Credential prompt doesn't appear
+
+**Check:**
+- Subscribed to both `network` and `network.credentials`
+- Connection has `interactive: true`
+- Secret flags set to AGENT_OWNED (value: 1)
+- Broker registered successfully
+
+### Connection succeeds without prompting
+
+**Cause:** NetworkManager found saved credentials
+
+**Solution:** Delete existing connection first, or use different credentials
+
+### State updates seem delayed
+
+**Expected behavior:** State changes occur in rapid succession during connection
+
+**Solution:** Debounce UI updates; only act on final state
+
+### Multiple rapid credential prompts
+
+**Cause:** Connection profile has incorrect flags or conflicting agents
+
+**Solution:**
+- Check only one agent is running
+- Verify psk-flags value
+- Check NetworkManager logs for agent conflicts
+
+## Data Structures Reference
+
+### PromptRequest
+```go
+type PromptRequest struct {
+ SSID string `json:"ssid"`
+ SettingName string `json:"setting"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Reason string `json:"reason"`
+}
+```
+
+### PromptReply
+```go
+type PromptReply struct {
+ Secrets map[string]string `json:"secrets"`
+ Save bool `json:"save"`
+ Cancel bool `json:"cancel"`
+}
+```
+
+### NetworkState
+```go
+type NetworkState struct {
+ NetworkStatus string `json:"networkStatus"`
+ IsConnecting bool `json:"isConnecting"`
+ ConnectingSSID string `json:"connectingSSID"`
+ WifiConnected bool `json:"wifiConnected"`
+ WifiSSID string `json:"wifiSSID"`
+ WifiIP string `json:"wifiIP"`
+ LastError string `json:"lastError"`
+}
+```
diff --git a/backend/internal/server/network/agent_iwd.go b/backend/internal/server/network/agent_iwd.go
new file mode 100644
index 00000000..0cd6684c
--- /dev/null
+++ b/backend/internal/server/network/agent_iwd.go
@@ -0,0 +1,306 @@
+package network
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ iwdAgentManagerPath = "/net/connman/iwd"
+ iwdAgentManagerIface = "net.connman.iwd.AgentManager"
+ iwdAgentInterface = "net.connman.iwd.Agent"
+ iwdAgentObjectPath = "/com/danklinux/iwdagent"
+)
+
+type ConnectionStateChecker interface {
+ IsConnectingTo(ssid string) bool
+}
+
+type IWDAgent struct {
+ conn *dbus.Conn
+ objPath dbus.ObjectPath
+ prompts PromptBroker
+ onUserCanceled func()
+ onPromptRetry func(ssid string)
+ lastRequestSSID string
+ stateChecker ConnectionStateChecker
+}
+
+const iwdAgentIntrospectXML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+
+func NewIWDAgent(conn *dbus.Conn, prompts PromptBroker) (*IWDAgent, error) {
+ if conn == nil {
+ return nil, fmt.Errorf("dbus connection is nil")
+ }
+
+ agent := &IWDAgent{
+ conn: conn,
+ objPath: dbus.ObjectPath(iwdAgentObjectPath),
+ prompts: prompts,
+ }
+
+ if err := conn.Export(agent, agent.objPath, iwdAgentInterface); err != nil {
+ return nil, fmt.Errorf("failed to export IWD agent: %w", err)
+ }
+
+ if err := conn.Export(agent, agent.objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
+ return nil, fmt.Errorf("failed to export introspection: %w", err)
+ }
+
+ mgr := conn.Object("net.connman.iwd", dbus.ObjectPath(iwdAgentManagerPath))
+ call := mgr.Call(iwdAgentManagerIface+".RegisterAgent", 0, agent.objPath)
+ if call.Err != nil {
+ return nil, fmt.Errorf("failed to register agent with iwd: %w", call.Err)
+ }
+
+ return agent, nil
+}
+
+func (a *IWDAgent) Close() {
+ if a.conn != nil {
+ mgr := a.conn.Object("net.connman.iwd", dbus.ObjectPath(iwdAgentManagerPath))
+ mgr.Call(iwdAgentManagerIface+".UnregisterAgent", 0, a.objPath)
+ }
+}
+
+func (a *IWDAgent) SetStateChecker(checker ConnectionStateChecker) {
+ a.stateChecker = checker
+}
+
+func (a *IWDAgent) getNetworkName(networkPath dbus.ObjectPath) string {
+ netObj := a.conn.Object("net.connman.iwd", networkPath)
+ nameVar, err := netObj.GetProperty("net.connman.iwd.Network.Name")
+ if err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ return name
+ }
+ }
+ return string(networkPath)
+}
+
+func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"psk"},
+ })
+ if err != nil {
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil {
+ if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if passphrase, ok := reply.Secrets["psk"]; ok {
+ return passphrase, nil
+ }
+
+ if a.onUserCanceled != nil {
+ a.onUserCanceled()
+ }
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) RequestPrivateKeyPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"private-key-password"},
+ })
+ if err != nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil || reply.Cancel {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if passphrase, ok := reply.Secrets["private-key-password"]; ok {
+ return passphrase, nil
+ }
+
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) RequestUserNameAndPassword(network dbus.ObjectPath) (string, string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"identity", "password"},
+ })
+ if err != nil {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil || reply.Cancel {
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ username, hasUser := reply.Secrets["identity"]
+ password, hasPass := reply.Secrets["password"]
+
+ if hasUser && hasPass {
+ return username, password, nil
+ }
+
+ return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) RequestUserPassword(network dbus.ObjectPath, user string) (string, *dbus.Error) {
+ ssid := a.getNetworkName(network)
+
+ if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.prompts == nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if a.lastRequestSSID == ssid {
+ if a.onPromptRetry != nil {
+ a.onPromptRetry(ssid)
+ }
+ }
+ a.lastRequestSSID = ssid
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ SSID: ssid,
+ Fields: []string{"password"},
+ })
+ if err != nil {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil || reply.Cancel {
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+ }
+
+ if password, ok := reply.Secrets["password"]; ok {
+ return password, nil
+ }
+
+ return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
+}
+
+func (a *IWDAgent) Cancel(reason string) *dbus.Error {
+ return nil
+}
+
+func (a *IWDAgent) Release() *dbus.Error {
+ return nil
+}
+
+func (a *IWDAgent) Introspect() (string, *dbus.Error) {
+ return iwdAgentIntrospectXML, nil
+}
diff --git a/backend/internal/server/network/agent_networkmanager.go b/backend/internal/server/network/agent_networkmanager.go
new file mode 100644
index 00000000..91e2fd38
--- /dev/null
+++ b/backend/internal/server/network/agent_networkmanager.go
@@ -0,0 +1,528 @@
+package network
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ nmAgentManagerPath = "/org/freedesktop/NetworkManager/AgentManager"
+ nmAgentManagerIface = "org.freedesktop.NetworkManager.AgentManager"
+ nmSecretAgentIface = "org.freedesktop.NetworkManager.SecretAgent"
+ agentObjectPath = "/org/freedesktop/NetworkManager/SecretAgent"
+ agentIdentifier = "com.danklinux.NMAgent"
+)
+
+type SecretAgent struct {
+ conn *dbus.Conn
+ objPath dbus.ObjectPath
+ id string
+ prompts PromptBroker
+ manager *Manager
+ backend *NetworkManagerBackend
+}
+
+type nmVariantMap map[string]dbus.Variant
+type nmSettingMap map[string]nmVariantMap
+
+const introspectXML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+
+func NewSecretAgent(prompts PromptBroker, manager *Manager, backend *NetworkManagerBackend) (*SecretAgent, error) {
+ c, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ sa := &SecretAgent{
+ conn: c,
+ objPath: dbus.ObjectPath(agentObjectPath),
+ id: agentIdentifier,
+ prompts: prompts,
+ manager: manager,
+ backend: backend,
+ }
+
+ if err := c.Export(sa, sa.objPath, nmSecretAgentIface); err != nil {
+ c.Close()
+ return nil, fmt.Errorf("failed to export secret agent: %w", err)
+ }
+
+ if err := c.Export(sa, sa.objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
+ c.Close()
+ return nil, fmt.Errorf("failed to export introspection: %w", err)
+ }
+
+ mgr := c.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(nmAgentManagerPath))
+ call := mgr.Call(nmAgentManagerIface+".Register", 0, sa.id)
+ if call.Err != nil {
+ c.Close()
+ return nil, fmt.Errorf("failed to register agent with NetworkManager: %w", call.Err)
+ }
+
+ log.Infof("[SecretAgent] Registered with NetworkManager (id=%s, unique name=%s, fixed path=%s)", sa.id, c.Names()[0], sa.objPath)
+ return sa, nil
+}
+
+func (a *SecretAgent) Close() {
+ if a.conn != nil {
+ mgr := a.conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(nmAgentManagerPath))
+ mgr.Call(nmAgentManagerIface+".Unregister", 0, a.id)
+ a.conn.Close()
+ }
+}
+
+func (a *SecretAgent) GetSecrets(
+ conn map[string]nmVariantMap,
+ path dbus.ObjectPath,
+ settingName string,
+ hints []string,
+ flags uint32,
+) (nmSettingMap, *dbus.Error) {
+ log.Infof("[SecretAgent] GetSecrets called: path=%s, setting=%s, hints=%v, flags=%d",
+ path, settingName, hints, flags)
+
+ const (
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_ALLOW_INTERACTION = 0x1
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_REQUEST_NEW = 0x2
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_USER_REQUESTED = 0x4
+ )
+
+ connType, displayName, vpnSvc := readConnTypeAndName(conn)
+ ssid := readSSID(conn)
+ fields := fieldsNeeded(settingName, hints)
+
+ log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d", connType, displayName, vpnSvc, fields, flags)
+
+ if a.backend != nil {
+ a.backend.stateMutex.RLock()
+ isConnecting := a.backend.state.IsConnecting
+ connectingSSID := a.backend.state.ConnectingSSID
+ isConnectingVPN := a.backend.state.IsConnectingVPN
+ connectingVPNUUID := a.backend.state.ConnectingVPNUUID
+ a.backend.stateMutex.RUnlock()
+
+ switch connType {
+ case "802-11-wireless":
+ // If we're connecting to a WiFi network, only respond if it's the one we're connecting to
+ if isConnecting && connectingSSID != ssid {
+ log.Infof("[SecretAgent] Ignoring WiFi request for SSID '%s' - we're connecting to '%s'", ssid, connectingSSID)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+ case "vpn", "wireguard":
+ var connUuid string
+ if c, ok := conn["connection"]; ok {
+ if v, ok := c["uuid"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connUuid = s
+ }
+ }
+ }
+
+ // If we're connecting to a VPN, only respond if it's the one we're connecting to
+ // This prevents interfering with nmcli/other tools when our app isn't connecting
+ if isConnectingVPN && connUuid != connectingVPNUUID {
+ log.Infof("[SecretAgent] Ignoring VPN request for UUID '%s' - we're connecting to '%s'", connUuid, connectingVPNUUID)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+ }
+ }
+
+ if len(fields) == 0 {
+ // For VPN connections with no hints, we can't provide a proper UI.
+ // Defer to other agents (like nm-applet or VPN-specific auth dialogs)
+ // that can handle the VPN type properly (e.g., OpenConnect with SAML, etc.)
+ if settingName == "vpn" {
+ log.Infof("[SecretAgent] VPN with empty hints - deferring to other agents for %s", vpnSvc)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ }
+
+ const (
+ NM_SETTING_SECRET_FLAG_NONE = 0
+ NM_SETTING_SECRET_FLAG_AGENT_OWNED = 1
+ NM_SETTING_SECRET_FLAG_NOT_SAVED = 2
+ NM_SETTING_SECRET_FLAG_NOT_REQUIRED = 4
+ )
+
+ var passwordFlags uint32 = 0xFFFF
+ switch settingName {
+ case "802-11-wireless-security":
+ if wifiSecSettings, ok := conn["802-11-wireless-security"]; ok {
+ if flagsVariant, ok := wifiSecSettings["psk-flags"]; ok {
+ if pwdFlags, ok := flagsVariant.Value().(uint32); ok {
+ passwordFlags = pwdFlags
+ }
+ }
+ }
+ case "802-1x":
+ if dot1xSettings, ok := conn["802-1x"]; ok {
+ if flagsVariant, ok := dot1xSettings["password-flags"]; ok {
+ if pwdFlags, ok := flagsVariant.Value().(uint32); ok {
+ passwordFlags = pwdFlags
+ }
+ }
+ }
+ }
+
+ if passwordFlags == 0xFFFF {
+ log.Warnf("[SecretAgent] Could not determine password-flags for empty hints - returning NoSecrets error")
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ } else if passwordFlags&NM_SETTING_SECRET_FLAG_NOT_REQUIRED != 0 {
+ log.Infof("[SecretAgent] Secrets not required (flags=%d)", passwordFlags)
+ out := nmSettingMap{}
+ out[settingName] = nmVariantMap{}
+ return out, nil
+ } else if passwordFlags&NM_SETTING_SECRET_FLAG_AGENT_OWNED != 0 {
+ log.Warnf("[SecretAgent] Secrets are agent-owned but we don't store secrets (flags=%d) - returning NoSecrets error", passwordFlags)
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
+ } else {
+ log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
+ out := nmSettingMap{}
+ out[settingName] = nmVariantMap{}
+ return out, nil
+ }
+ }
+
+ reason := reasonFromFlags(flags)
+ if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
+ reason = "wrong-password"
+ }
+
+ var connId, connUuid string
+ if c, ok := conn["connection"]; ok {
+ if v, ok := c["id"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connId = s
+ }
+ }
+ if v, ok := c["uuid"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connUuid = s
+ }
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ token, err := a.prompts.Ask(ctx, PromptRequest{
+ Name: displayName,
+ SSID: ssid,
+ ConnType: connType,
+ VpnService: vpnSvc,
+ SettingName: settingName,
+ Fields: fields,
+ Hints: hints,
+ Reason: reason,
+ ConnectionId: connId,
+ ConnectionUuid: connUuid,
+ ConnectionPath: string(path),
+ })
+ if err != nil {
+ log.Warnf("[SecretAgent] Failed to create prompt: %v", err)
+ return nil, dbus.MakeFailedError(err)
+ }
+
+ log.Infof("[SecretAgent] Waiting for user input (token=%s)", token)
+ reply, err := a.prompts.Wait(ctx, token)
+ if err != nil {
+ log.Warnf("[SecretAgent] Prompt failed or cancelled: %v", err)
+
+ // Clear connecting state immediately on cancellation
+ if a.backend != nil {
+ a.backend.stateMutex.Lock()
+ wasConnecting := a.backend.state.IsConnecting
+ wasConnectingVPN := a.backend.state.IsConnectingVPN
+ cancelledSSID := a.backend.state.ConnectingSSID
+ if wasConnecting || wasConnectingVPN {
+ log.Infof("[SecretAgent] Clearing connecting state due to cancelled prompt")
+ a.backend.state.IsConnecting = false
+ a.backend.state.ConnectingSSID = ""
+ a.backend.state.IsConnectingVPN = false
+ a.backend.state.ConnectingVPNUUID = ""
+ }
+ a.backend.stateMutex.Unlock()
+
+ // If this was a WiFi connection that was just cancelled, remove the connection profile
+ // (it was created with AddConnection but activation was cancelled)
+ if wasConnecting && cancelledSSID != "" && connType == "802-11-wireless" {
+ log.Infof("[SecretAgent] Removing connection profile for cancelled WiFi connection: %s", cancelledSSID)
+ if err := a.backend.ForgetWiFiNetwork(cancelledSSID); err != nil {
+ log.Warnf("[SecretAgent] Failed to remove cancelled connection profile: %v", err)
+ }
+ }
+
+ if (wasConnecting || wasConnectingVPN) && a.backend.onStateChange != nil {
+ a.backend.onStateChange()
+ }
+ }
+
+ if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.UserCanceled", nil)
+ }
+
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.Failed", nil)
+ }
+ return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.Failed", nil)
+ }
+
+ log.Infof("[SecretAgent] User provided secrets, save=%v", reply.Save)
+
+ out := nmSettingMap{}
+ sec := nmVariantMap{}
+ for k, v := range reply.Secrets {
+ sec[k] = dbus.MakeVariant(v)
+ }
+ out[settingName] = sec
+
+ switch settingName {
+ case "802-1x":
+ log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(sec))
+ case "vpn":
+ log.Infof("[SecretAgent] Returning VPN secrets with %d fields for %s", len(sec), vpnSvc)
+ }
+
+ // If save=true, persist secrets in background after returning to NetworkManager
+ // This MUST happen after we return secrets, in a goroutine
+ if reply.Save {
+ go func() {
+ log.Infof("[SecretAgent] Persisting secrets with Update2: path=%s, setting=%s", path, settingName)
+
+ // Get existing connection settings
+ connObj := a.conn.Object("org.freedesktop.NetworkManager", path)
+ var existingSettings map[string]map[string]dbus.Variant
+ if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
+ log.Warnf("[SecretAgent] GetSettings failed: %v", err)
+ return
+ }
+
+ // Build minimal settings with ONLY the section we're updating
+ // This avoids D-Bus type serialization issues with complex types like IPv6 addresses
+ settings := make(map[string]map[string]dbus.Variant)
+
+ // Copy connection section (required for Update2)
+ if connSection, ok := existingSettings["connection"]; ok {
+ settings["connection"] = connSection
+ }
+
+ // Update settings based on type
+ switch settingName {
+ case "vpn":
+ // Set password-flags=0 and add secrets to vpn section
+ vpn, ok := existingSettings["vpn"]
+ if !ok {
+ vpn = make(map[string]dbus.Variant)
+ }
+
+ // Get existing data map (vpn.data is string->string)
+ var data map[string]string
+ if dataVariant, ok := vpn["data"]; ok {
+ if dm, ok := dataVariant.Value().(map[string]string); ok {
+ data = make(map[string]string)
+ for k, v := range dm {
+ data[k] = v
+ }
+ } else {
+ data = make(map[string]string)
+ }
+ } else {
+ data = make(map[string]string)
+ }
+
+ // Update password-flags to 0 (system-stored)
+ data["password-flags"] = "0"
+ vpn["data"] = dbus.MakeVariant(data)
+
+ // Add secrets (vpn.secrets is string->string)
+ secs := make(map[string]string)
+ for k, v := range reply.Secrets {
+ secs[k] = v
+ }
+ vpn["secrets"] = dbus.MakeVariant(secs)
+ settings["vpn"] = vpn
+
+ log.Infof("[SecretAgent] Updated VPN settings: password-flags=0, secrets with %d fields", len(secs))
+
+ case "802-11-wireless-security":
+ // Set psk-flags=0 for WiFi
+ wifiSec, ok := existingSettings["802-11-wireless-security"]
+ if !ok {
+ wifiSec = make(map[string]dbus.Variant)
+ }
+ wifiSec["psk-flags"] = dbus.MakeVariant(uint32(0))
+
+ // Add PSK secret
+ if psk, ok := reply.Secrets["psk"]; ok {
+ wifiSec["psk"] = dbus.MakeVariant(psk)
+ log.Infof("[SecretAgent] Updated WiFi settings: psk-flags=0")
+ }
+ settings["802-11-wireless-security"] = wifiSec
+
+ case "802-1x":
+ // Set password-flags=0 for 802.1x
+ dot1x, ok := existingSettings["802-1x"]
+ if !ok {
+ dot1x = make(map[string]dbus.Variant)
+ }
+ dot1x["password-flags"] = dbus.MakeVariant(uint32(0))
+
+ // Add password secret
+ if password, ok := reply.Secrets["password"]; ok {
+ dot1x["password"] = dbus.MakeVariant(password)
+ log.Infof("[SecretAgent] Updated 802.1x settings: password-flags=0")
+ }
+ settings["802-1x"] = dot1x
+ }
+
+ // Call Update2 with correct signature:
+ // Update2(IN settings, IN flags, IN args) -> OUT result
+ // flags: 0x1 = to-disk
+ var result map[string]dbus.Variant
+ err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
+ settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result)
+ if err != nil {
+ log.Warnf("[SecretAgent] Update2(to-disk) failed: %v", err)
+ } else {
+ log.Infof("[SecretAgent] Successfully persisted secrets to disk for %s", settingName)
+ }
+ }()
+ }
+
+ return out, nil
+}
+
+func (a *SecretAgent) DeleteSecrets(conn map[string]nmVariantMap, path dbus.ObjectPath) *dbus.Error {
+ ssid := readSSID(conn)
+ log.Infof("[SecretAgent] DeleteSecrets called: path=%s, SSID=%s", path, ssid)
+ return nil
+}
+
+func (a *SecretAgent) DeleteSecrets2(path dbus.ObjectPath, setting string) *dbus.Error {
+ log.Infof("[SecretAgent] DeleteSecrets2 (alternate) called: path=%s, setting=%s", path, setting)
+ return nil
+}
+
+func (a *SecretAgent) CancelGetSecrets(path dbus.ObjectPath, settingName string) *dbus.Error {
+ log.Infof("[SecretAgent] CancelGetSecrets called: path=%s, setting=%s", path, settingName)
+
+ if a.prompts != nil {
+ if err := a.prompts.Cancel(string(path), settingName); err != nil {
+ log.Warnf("[SecretAgent] Failed to cancel prompt: %v", err)
+ }
+ }
+
+ return nil
+}
+
+func (a *SecretAgent) Introspect() (string, *dbus.Error) {
+ return introspectXML, nil
+}
+
+func readSSID(conn map[string]nmVariantMap) string {
+ if w, ok := conn["802-11-wireless"]; ok {
+ if v, ok := w["ssid"]; ok {
+ if b, ok := v.Value().([]byte); ok {
+ return string(b)
+ }
+ if s, ok := v.Value().(string); ok {
+ return s
+ }
+ }
+ }
+ return ""
+}
+
+func readConnTypeAndName(conn map[string]nmVariantMap) (string, string, string) {
+ var connType, name, svc string
+ if c, ok := conn["connection"]; ok {
+ if v, ok := c["type"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ connType = s
+ }
+ }
+ if v, ok := c["id"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ name = s
+ }
+ }
+ }
+ if vpn, ok := conn["vpn"]; ok {
+ if v, ok := vpn["service-type"]; ok {
+ if s, ok2 := v.Value().(string); ok2 {
+ svc = s
+ }
+ }
+ }
+ if name == "" && connType == "802-11-wireless" {
+ name = readSSID(conn)
+ }
+ return connType, name, svc
+}
+
+func fieldsNeeded(setting string, hints []string) []string {
+ switch setting {
+ case "802-11-wireless-security":
+ return []string{"psk"}
+ case "802-1x":
+ return []string{"identity", "password"}
+ case "vpn":
+ return hints
+ default:
+ return []string{}
+ }
+}
+
+func reasonFromFlags(flags uint32) string {
+ const (
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_NONE = 0x0
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_ALLOW_INTERACTION = 0x1
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_REQUEST_NEW = 0x2
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_USER_REQUESTED = 0x4
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_WPS_PBC_ACTIVE = 0x8
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_ONLY_SYSTEM = 0x80000000
+ NM_SECRET_AGENT_GET_SECRETS_FLAG_NO_ERRORS = 0x40000000
+ )
+
+ if flags&NM_SECRET_AGENT_GET_SECRETS_FLAG_REQUEST_NEW != 0 {
+ return "wrong-password"
+ }
+ if flags&NM_SECRET_AGENT_GET_SECRETS_FLAG_USER_REQUESTED != 0 {
+ return "user-requested"
+ }
+ return "required"
+}
diff --git a/backend/internal/server/network/backend.go b/backend/internal/server/network/backend.go
new file mode 100644
index 00000000..3925e2a1
--- /dev/null
+++ b/backend/internal/server/network/backend.go
@@ -0,0 +1,65 @@
+package network
+
+type Backend interface {
+ Initialize() error
+ Close()
+
+ GetWiFiEnabled() (bool, error)
+ SetWiFiEnabled(enabled bool) error
+
+ ScanWiFi() error
+ GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error)
+
+ ConnectWiFi(req ConnectionRequest) error
+ DisconnectWiFi() error
+ ForgetWiFiNetwork(ssid string) error
+ SetWiFiAutoconnect(ssid string, autoconnect bool) error
+
+ GetWiredConnections() ([]WiredConnection, error)
+ GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error)
+ ConnectEthernet() error
+ DisconnectEthernet() error
+ ActivateWiredConnection(uuid string) error
+
+ ListVPNProfiles() ([]VPNProfile, error)
+ ListActiveVPN() ([]VPNActive, error)
+ ConnectVPN(uuidOrName string, singleActive bool) error
+ DisconnectVPN(uuidOrName string) error
+ DisconnectAllVPN() error
+ ClearVPNCredentials(uuidOrName string) error
+
+ GetCurrentState() (*BackendState, error)
+
+ StartMonitoring(onStateChange func()) error
+ StopMonitoring()
+
+ GetPromptBroker() PromptBroker
+ SetPromptBroker(broker PromptBroker) error
+ SubmitCredentials(token string, secrets map[string]string, save bool) error
+ CancelCredentials(token string) error
+}
+
+type BackendState struct {
+ Backend string
+ NetworkStatus NetworkStatus
+ EthernetIP string
+ EthernetDevice string
+ EthernetConnected bool
+ EthernetConnectionUuid string
+ WiFiIP string
+ WiFiDevice string
+ WiFiConnected bool
+ WiFiEnabled bool
+ WiFiSSID string
+ WiFiBSSID string
+ WiFiSignal uint8
+ WiFiNetworks []WiFiNetwork
+ WiredConnections []WiredConnection
+ VPNProfiles []VPNProfile
+ VPNActive []VPNActive
+ IsConnecting bool
+ ConnectingSSID string
+ IsConnectingVPN bool
+ ConnectingVPNUUID string
+ LastError string
+}
diff --git a/backend/internal/server/network/backend_hybrid_iwd_networkd.go b/backend/internal/server/network/backend_hybrid_iwd_networkd.go
new file mode 100644
index 00000000..1df3a9a2
--- /dev/null
+++ b/backend/internal/server/network/backend_hybrid_iwd_networkd.go
@@ -0,0 +1,198 @@
+package network
+
+import (
+ "fmt"
+ "sync"
+)
+
+type HybridIwdNetworkdBackend struct {
+ wifi *IWDBackend
+ l3 *SystemdNetworkdBackend
+ onStateChange func()
+ stateMutex sync.RWMutex
+}
+
+func NewHybridIwdNetworkdBackend(w *IWDBackend, n *SystemdNetworkdBackend) (*HybridIwdNetworkdBackend, error) {
+ return &HybridIwdNetworkdBackend{
+ wifi: w,
+ l3: n,
+ }, nil
+}
+
+func (b *HybridIwdNetworkdBackend) Initialize() error {
+ if err := b.wifi.Initialize(); err != nil {
+ return fmt.Errorf("iwd init: %w", err)
+ }
+ if err := b.l3.Initialize(); err != nil {
+ return fmt.Errorf("networkd init: %w", err)
+ }
+ return nil
+}
+
+func (b *HybridIwdNetworkdBackend) Close() {
+ b.wifi.Close()
+ b.l3.Close()
+}
+
+func (b *HybridIwdNetworkdBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ mergedCallback := func() {
+ ws, _ := b.wifi.GetCurrentState()
+ ls, _ := b.l3.GetCurrentState()
+
+ if ws != nil && ls != nil && ws.WiFiDevice != "" && ls.WiFiIP != "" {
+ b.wifi.MarkIPConfigSeen()
+ }
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+
+ if err := b.wifi.StartMonitoring(mergedCallback); err != nil {
+ return fmt.Errorf("wifi monitoring: %w", err)
+ }
+ if err := b.l3.StartMonitoring(mergedCallback); err != nil {
+ return fmt.Errorf("l3 monitoring: %w", err)
+ }
+
+ return nil
+}
+
+func (b *HybridIwdNetworkdBackend) StopMonitoring() {
+ b.wifi.StopMonitoring()
+ b.l3.StopMonitoring()
+}
+
+func (b *HybridIwdNetworkdBackend) GetCurrentState() (*BackendState, error) {
+ ws, err := b.wifi.GetCurrentState()
+ if err != nil {
+ return nil, err
+ }
+ ls, err := b.l3.GetCurrentState()
+ if err != nil {
+ return nil, err
+ }
+
+ merged := *ws
+ merged.Backend = "iwd+networkd"
+
+ merged.WiFiIP = ls.WiFiIP
+ merged.EthernetConnected = ls.EthernetConnected
+ merged.EthernetIP = ls.EthernetIP
+ merged.EthernetDevice = ls.EthernetDevice
+ merged.EthernetConnectionUuid = ls.EthernetConnectionUuid
+ merged.WiredConnections = ls.WiredConnections
+
+ if ls.EthernetConnected && ls.EthernetIP != "" {
+ merged.NetworkStatus = StatusEthernet
+ } else if ws.WiFiConnected && ls.WiFiIP != "" {
+ merged.NetworkStatus = StatusWiFi
+ } else {
+ merged.NetworkStatus = StatusDisconnected
+ }
+
+ return &merged, nil
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiFiEnabled() (bool, error) {
+ return b.wifi.GetWiFiEnabled()
+}
+
+func (b *HybridIwdNetworkdBackend) SetWiFiEnabled(enabled bool) error {
+ return b.wifi.SetWiFiEnabled(enabled)
+}
+
+func (b *HybridIwdNetworkdBackend) ScanWiFi() error {
+ return b.wifi.ScanWiFi()
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ return b.wifi.GetWiFiNetworkDetails(ssid)
+}
+
+func (b *HybridIwdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error {
+ if err := b.wifi.ConnectWiFi(req); err != nil {
+ return err
+ }
+
+ ws, err := b.wifi.GetCurrentState()
+ if err == nil && ws.WiFiDevice != "" {
+ b.l3.EnsureDhcpUp(ws.WiFiDevice)
+ }
+
+ return nil
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectWiFi() error {
+ return b.wifi.DisconnectWiFi()
+}
+
+func (b *HybridIwdNetworkdBackend) ForgetWiFiNetwork(ssid string) error {
+ return b.wifi.ForgetWiFiNetwork(ssid)
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error) {
+ return b.l3.GetWiredConnections()
+}
+
+func (b *HybridIwdNetworkdBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) {
+ return b.l3.GetWiredNetworkDetails(uuid)
+}
+
+func (b *HybridIwdNetworkdBackend) ConnectEthernet() error {
+ return b.l3.ConnectEthernet()
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectEthernet() error {
+ return b.l3.DisconnectEthernet()
+}
+
+func (b *HybridIwdNetworkdBackend) ActivateWiredConnection(uuid string) error {
+ return b.l3.ActivateWiredConnection(uuid)
+}
+
+func (b *HybridIwdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ return []VPNProfile{}, nil
+}
+
+func (b *HybridIwdNetworkdBackend) ListActiveVPN() ([]VPNActive, error) {
+ return []VPNActive{}, nil
+}
+
+func (b *HybridIwdNetworkdBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) DisconnectAllVPN() error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported in hybrid mode")
+}
+
+func (b *HybridIwdNetworkdBackend) GetPromptBroker() PromptBroker {
+ return b.wifi.GetPromptBroker()
+}
+
+func (b *HybridIwdNetworkdBackend) SetPromptBroker(broker PromptBroker) error {
+ return b.wifi.SetPromptBroker(broker)
+}
+
+func (b *HybridIwdNetworkdBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ return b.wifi.SubmitCredentials(token, secrets, save)
+}
+
+func (b *HybridIwdNetworkdBackend) CancelCredentials(token string) error {
+ return b.wifi.CancelCredentials(token)
+}
+
+func (b *HybridIwdNetworkdBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ return b.wifi.SetWiFiAutoconnect(ssid, autoconnect)
+}
diff --git a/backend/internal/server/network/backend_hybrid_test.go b/backend/internal/server/network/backend_hybrid_test.go
new file mode 100644
index 00000000..cd061ed1
--- /dev/null
+++ b/backend/internal/server/network/backend_hybrid_test.go
@@ -0,0 +1,135 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHybridIwdNetworkdBackend_New(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+
+ hybrid, err := NewHybridIwdNetworkdBackend(wifi, l3)
+ assert.NoError(t, err)
+ assert.NotNil(t, hybrid)
+ assert.NotNil(t, hybrid.wifi)
+ assert.NotNil(t, hybrid.l3)
+}
+
+func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ wifi.state.WiFiConnected = true
+ wifi.state.WiFiSSID = "TestNetwork"
+ wifi.state.WiFiBSSID = "00:11:22:33:44:55"
+ wifi.state.WiFiSignal = 75
+ wifi.state.WiFiDevice = "wlan0"
+
+ l3.state.WiFiIP = "192.168.1.100"
+ l3.state.EthernetConnected = false
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, "iwd+networkd", state.Backend)
+ assert.Equal(t, "TestNetwork", state.WiFiSSID)
+ assert.Equal(t, "00:11:22:33:44:55", state.WiFiBSSID)
+ assert.Equal(t, uint8(75), state.WiFiSignal)
+ assert.Equal(t, "192.168.1.100", state.WiFiIP)
+ assert.True(t, state.WiFiConnected)
+ assert.False(t, state.EthernetConnected)
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+}
+
+func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ wifi.state.WiFiConnected = true
+ wifi.state.WiFiSSID = "TestNetwork"
+
+ l3.state.WiFiIP = "192.168.1.100"
+ l3.state.EthernetConnected = true
+ l3.state.EthernetIP = "192.168.1.50"
+ l3.state.EthernetDevice = "eth0"
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.Equal(t, StatusEthernet, state.NetworkStatus)
+ assert.Equal(t, "192.168.1.50", state.EthernetIP)
+ assert.Equal(t, "eth0", state.EthernetDevice)
+}
+
+func TestHybridIwdNetworkdBackend_GetCurrentState_WiFiNoIP(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ wifi.state.WiFiConnected = true
+ wifi.state.WiFiSSID = "TestNetwork"
+
+ l3.state.WiFiIP = ""
+ l3.state.EthernetConnected = false
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.Equal(t, StatusDisconnected, state.NetworkStatus)
+ assert.True(t, state.WiFiConnected)
+ assert.Empty(t, state.WiFiIP)
+}
+
+func TestHybridIwdNetworkdBackend_WiFiDelegation(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ enabled, err := hybrid.GetWiFiEnabled()
+ assert.NoError(t, err)
+ assert.True(t, enabled)
+
+ state, err := hybrid.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, "iwd+networkd", state.Backend)
+}
+
+func TestHybridIwdNetworkdBackend_WiredDelegation(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ conns, err := hybrid.GetWiredConnections()
+ assert.NoError(t, err)
+ assert.Empty(t, conns)
+}
+
+func TestHybridIwdNetworkdBackend_VPNNotSupported(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ profiles, err := hybrid.ListVPNProfiles()
+ assert.NoError(t, err)
+ assert.Empty(t, profiles)
+
+ active, err := hybrid.ListActiveVPN()
+ assert.NoError(t, err)
+ assert.Empty(t, active)
+
+ err = hybrid.ConnectVPN("test", false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestHybridIwdNetworkdBackend_PromptBrokerDelegation(t *testing.T) {
+ wifi, _ := NewIWDBackend()
+ l3, _ := NewSystemdNetworkdBackend()
+ hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3)
+
+ broker := hybrid.GetPromptBroker()
+ assert.Nil(t, broker)
+}
diff --git a/backend/internal/server/network/backend_iwd.go b/backend/internal/server/network/backend_iwd.go
new file mode 100644
index 00000000..353dc058
--- /dev/null
+++ b/backend/internal/server/network/backend_iwd.go
@@ -0,0 +1,232 @@
+package network
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ iwdBusName = "net.connman.iwd"
+ iwdObjectPath = "/"
+ iwdAdapterInterface = "net.connman.iwd.Adapter"
+ iwdDeviceInterface = "net.connman.iwd.Device"
+ iwdStationInterface = "net.connman.iwd.Station"
+ iwdNetworkInterface = "net.connman.iwd.Network"
+ iwdKnownNetworkInterface = "net.connman.iwd.KnownNetwork"
+ dbusObjectManager = "org.freedesktop.DBus.ObjectManager"
+ dbusPropertiesInterface = "org.freedesktop.DBus.Properties"
+)
+
+type connectAttempt struct {
+ ssid string
+ netPath dbus.ObjectPath
+ start time.Time
+ deadline time.Time
+ sawAuthish bool
+ connectedAt time.Time
+ sawIPConfig bool
+ sawPromptRetry bool
+ finalized bool
+ mu sync.Mutex
+}
+
+type IWDBackend struct {
+ conn *dbus.Conn
+ state *BackendState
+ stateMutex sync.RWMutex
+ promptBroker PromptBroker
+ onStateChange func()
+
+ devicePath dbus.ObjectPath
+ stationPath dbus.ObjectPath
+ adapterPath dbus.ObjectPath
+
+ iwdAgent *IWDAgent
+
+ stopChan chan struct{}
+ sigWG sync.WaitGroup
+ curAttempt *connectAttempt
+ attemptMutex sync.RWMutex
+ recentScans map[string]time.Time
+ recentScansMu sync.Mutex
+}
+
+func NewIWDBackend() (*IWDBackend, error) {
+ backend := &IWDBackend{
+ state: &BackendState{
+ Backend: "iwd",
+ WiFiEnabled: true,
+ },
+ stopChan: make(chan struct{}),
+ recentScans: make(map[string]time.Time),
+ }
+
+ return backend, nil
+}
+
+func (b *IWDBackend) Initialize() error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+ b.conn = conn
+
+ if err := b.discoverDevices(); err != nil {
+ conn.Close()
+ return fmt.Errorf("failed to discover iwd devices: %w", err)
+ }
+
+ if err := b.updateState(); err != nil {
+ conn.Close()
+ return fmt.Errorf("failed to get initial state: %w", err)
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) Close() {
+ close(b.stopChan)
+ b.sigWG.Wait()
+
+ if b.iwdAgent != nil {
+ b.iwdAgent.Close()
+ }
+
+ if b.conn != nil {
+ b.conn.Close()
+ }
+}
+
+func (b *IWDBackend) discoverDevices() error {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return fmt.Errorf("failed to get managed objects: %w", err)
+ }
+
+ for path, interfaces := range objects {
+ if _, hasStation := interfaces[iwdStationInterface]; hasStation {
+ b.stationPath = path
+ }
+ if _, hasDevice := interfaces[iwdDeviceInterface]; hasDevice {
+ b.devicePath = path
+
+ if devProps, ok := interfaces[iwdDeviceInterface]; ok {
+ if nameVar, ok := devProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiDevice = name
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+ }
+ if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter {
+ b.adapterPath = path
+ }
+ }
+
+ if b.stationPath == "" || b.devicePath == "" {
+ return fmt.Errorf("no WiFi device found")
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
+ state := *b.state
+ state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
+ state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
+
+ return &state, nil
+}
+
+func (b *IWDBackend) OnUserCanceledPrompt() {
+ b.stateMutex.RLock()
+ cancelledSSID := b.state.ConnectingSSID
+ b.stateMutex.RUnlock()
+
+ b.setConnectError("user-canceled")
+
+ if cancelledSSID != "" {
+ if err := b.ForgetWiFiNetwork(cancelledSSID); err != nil {
+ }
+ }
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+}
+
+func (b *IWDBackend) OnPromptRetry(ssid string) {
+ b.attemptMutex.RLock()
+ att := b.curAttempt
+ b.attemptMutex.RUnlock()
+
+ if att != nil && att.ssid == ssid {
+ att.mu.Lock()
+ att.sawPromptRetry = true
+ att.mu.Unlock()
+ }
+}
+
+func (b *IWDBackend) MarkIPConfigSeen() {
+ b.attemptMutex.RLock()
+ att := b.curAttempt
+ b.attemptMutex.RUnlock()
+ if att != nil {
+ att.mu.Lock()
+ att.sawIPConfig = true
+ att.mu.Unlock()
+ }
+}
+
+func (b *IWDBackend) GetPromptBroker() PromptBroker {
+ return b.promptBroker
+}
+
+func (b *IWDBackend) SetPromptBroker(broker PromptBroker) error {
+ if broker == nil {
+ return fmt.Errorf("broker cannot be nil")
+ }
+
+ b.promptBroker = broker
+ return nil
+}
+
+func (b *IWDBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Secrets: secrets,
+ Save: save,
+ Cancel: false,
+ })
+}
+
+func (b *IWDBackend) CancelCredentials(token string) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+}
+
+func (b *IWDBackend) StopMonitoring() {
+ select {
+ case <-b.stopChan:
+ return
+ default:
+ close(b.stopChan)
+ }
+ b.sigWG.Wait()
+}
diff --git a/backend/internal/server/network/backend_iwd_signals.go b/backend/internal/server/network/backend_iwd_signals.go
new file mode 100644
index 00000000..bde4753f
--- /dev/null
+++ b/backend/internal/server/network/backend_iwd_signals.go
@@ -0,0 +1,355 @@
+package network
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ if b.promptBroker != nil {
+ agent, err := NewIWDAgent(b.conn, b.promptBroker)
+ if err != nil {
+ return fmt.Errorf("failed to start IWD agent: %w", err)
+ }
+ agent.onUserCanceled = b.OnUserCanceledPrompt
+ agent.onPromptRetry = b.OnPromptRetry
+ b.iwdAgent = agent
+ }
+
+ sigChan := make(chan *dbus.Signal, 100)
+ b.conn.Signal(sigChan)
+
+ if b.devicePath != "" {
+ err := b.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(b.devicePath),
+ dbus.WithMatchInterface(dbusPropertiesInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to add device signal match: %w", err)
+ }
+ }
+
+ if b.stationPath != "" {
+ err := b.conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(b.stationPath),
+ dbus.WithMatchInterface(dbusPropertiesInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to add station signal match: %w", err)
+ }
+ }
+
+ b.sigWG.Add(1)
+ go b.signalHandler(sigChan)
+
+ return nil
+}
+
+func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
+ defer b.sigWG.Done()
+
+ for {
+ select {
+ case <-b.stopChan:
+ b.conn.RemoveSignal(sigChan)
+ close(sigChan)
+ return
+
+ case sig := <-sigChan:
+ if sig == nil {
+ return
+ }
+
+ if sig.Name != dbusPropertiesInterface+".PropertiesChanged" {
+ continue
+ }
+
+ if len(sig.Body) < 2 {
+ continue
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok {
+ continue
+ }
+
+ changed, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ continue
+ }
+
+ stateChanged := false
+
+ switch iface {
+ case iwdDeviceInterface:
+ if sig.Path == b.devicePath {
+ if poweredVar, ok := changed["Powered"]; ok {
+ if powered, ok := poweredVar.Value().(bool); ok {
+ b.stateMutex.Lock()
+ if b.state.WiFiEnabled != powered {
+ b.state.WiFiEnabled = powered
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+
+ case iwdStationInterface:
+ if sig.Path == b.stationPath {
+ if scanningVar, ok := changed["Scanning"]; ok {
+ if scanning, ok := scanningVar.Value().(bool); ok && !scanning {
+ networks, err := b.updateWiFiNetworks()
+ if err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+ stateChanged = true
+ }
+
+ b.stateMutex.RLock()
+ wifiConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ if wifiConnected {
+ stationObj := b.conn.Object(iwdBusName, b.stationPath)
+ connNetVar, err := stationObj.GetProperty(iwdStationInterface + ".ConnectedNetwork")
+ if err == nil && connNetVar.Value() != nil {
+ if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" {
+ var orderedNetworks [][]dbus.Variant
+ err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err == nil {
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+ currentNetPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok || currentNetPath != netPath {
+ continue
+ }
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+ b.stateMutex.Lock()
+ if b.state.WiFiSignal != signal {
+ b.state.WiFiSignal = signal
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ break
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if stateVar, ok := changed["State"]; ok {
+ if state, ok := stateVar.Value().(string); ok {
+ b.attemptMutex.RLock()
+ att := b.curAttempt
+ b.attemptMutex.RUnlock()
+
+ var connPath dbus.ObjectPath
+ if v, ok := changed["ConnectedNetwork"]; ok {
+ if v.Value() != nil {
+ if p, ok := v.Value().(dbus.ObjectPath); ok {
+ connPath = p
+ }
+ }
+ }
+ if connPath == "" {
+ station := b.conn.Object(iwdBusName, b.stationPath)
+ if cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork"); err == nil && cnVar.Value() != nil {
+ cnVar.Store(&connPath)
+ }
+ }
+
+ b.stateMutex.RLock()
+ prevConnected := b.state.WiFiConnected
+ prevSSID := b.state.WiFiSSID
+ b.stateMutex.RUnlock()
+
+ targetPath := dbus.ObjectPath("")
+ if att != nil {
+ targetPath = att.netPath
+ }
+
+ isTarget := att != nil && targetPath != "" && connPath == targetPath
+
+ if att != nil {
+ switch state {
+ case "authenticating", "associating", "associated", "roaming":
+ att.mu.Lock()
+ att.sawAuthish = true
+ att.mu.Unlock()
+ }
+ }
+
+ if att != nil && state == "connected" && isTarget {
+ att.mu.Lock()
+ if att.connectedAt.IsZero() {
+ att.connectedAt = time.Now()
+ }
+ att.mu.Unlock()
+ }
+
+ if att != nil && state == "configuring" {
+ att.mu.Lock()
+ att.sawIPConfig = true
+ att.mu.Unlock()
+ }
+
+ switch state {
+ case "connected":
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = true
+ b.state.NetworkStatus = StatusWiFi
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+
+ if connPath != "" && connPath != "/" {
+ netObj := b.conn.Object(iwdBusName, connPath)
+ if nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name"); err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiSSID = name
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+
+ stateChanged = true
+
+ if att != nil && isTarget {
+ go func(attLocal *connectAttempt, tgt dbus.ObjectPath) {
+ time.Sleep(3 * time.Second)
+ station := b.conn.Object(iwdBusName, b.stationPath)
+ var nowState string
+ if stVar, err := station.GetProperty(iwdStationInterface + ".State"); err == nil {
+ stVar.Store(&nowState)
+ }
+ var nowConn dbus.ObjectPath
+ if cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork"); err == nil && cnVar.Value() != nil {
+ cnVar.Store(&nowConn)
+ }
+
+ if nowState == "connected" && nowConn == tgt {
+ b.finalizeAttempt(attLocal, "")
+ b.attemptMutex.Lock()
+ if b.curAttempt == attLocal {
+ b.curAttempt = nil
+ }
+ b.attemptMutex.Unlock()
+ }
+ }(att, targetPath)
+ }
+
+ case "disconnecting", "disconnected":
+ if att != nil {
+ wasConnectedToTarget := prevConnected && prevSSID == att.ssid
+ if wasConnectedToTarget || isTarget {
+ code := b.classifyAttempt(att)
+ b.finalizeAttempt(att, code)
+ b.attemptMutex.Lock()
+ if b.curAttempt == att {
+ b.curAttempt = nil
+ }
+ b.attemptMutex.Unlock()
+ }
+ }
+
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = false
+ if state == "disconnected" {
+ b.state.NetworkStatus = StatusDisconnected
+ }
+ b.stateMutex.Unlock()
+ stateChanged = true
+ }
+ }
+ }
+
+ if connNetVar, ok := changed["ConnectedNetwork"]; ok {
+ if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" {
+ netObj := b.conn.Object(iwdBusName, netPath)
+ nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name")
+ if err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ if b.state.WiFiSSID != name {
+ b.state.WiFiSSID = name
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+
+ stationObj := b.conn.Object(iwdBusName, b.stationPath)
+ var orderedNetworks [][]dbus.Variant
+ err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err == nil {
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+ currentNetPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok || currentNetPath != netPath {
+ continue
+ }
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+ b.stateMutex.Lock()
+ if b.state.WiFiSignal != signal {
+ b.state.WiFiSignal = signal
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ break
+ }
+ }
+ } else {
+ b.stateMutex.Lock()
+ if b.state.WiFiSSID != "" {
+ b.state.WiFiSSID = ""
+ b.state.WiFiSignal = 0
+ stateChanged = true
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+ }
+ }
+
+ if stateChanged && b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+ }
+}
diff --git a/backend/internal/server/network/backend_iwd_test.go b/backend/internal/server/network/backend_iwd_test.go
new file mode 100644
index 00000000..829e4132
--- /dev/null
+++ b/backend/internal/server/network/backend_iwd_test.go
@@ -0,0 +1,212 @@
+package network
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIWDBackend_MarkIPConfigSeen(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/net/connman/iwd/0/1/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = att
+ backend.attemptMutex.Unlock()
+
+ backend.MarkIPConfigSeen()
+
+ att.mu.Lock()
+ assert.True(t, att.sawIPConfig, "sawIPConfig should be true after MarkIPConfigSeen")
+ att.mu.Unlock()
+}
+
+func TestIWDBackend_MarkIPConfigSeen_NoAttempt(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = nil
+ backend.attemptMutex.Unlock()
+
+ backend.MarkIPConfigSeen()
+}
+
+func TestIWDBackend_OnPromptRetry(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/net/connman/iwd/0/1/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = att
+ backend.attemptMutex.Unlock()
+
+ backend.OnPromptRetry("TestNetwork")
+
+ att.mu.Lock()
+ assert.True(t, att.sawPromptRetry, "sawPromptRetry should be true after OnPromptRetry")
+ att.mu.Unlock()
+}
+
+func TestIWDBackend_OnPromptRetry_WrongSSID(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/net/connman/iwd/0/1/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.attemptMutex.Lock()
+ backend.curAttempt = att
+ backend.attemptMutex.Unlock()
+
+ backend.OnPromptRetry("DifferentNetwork")
+
+ att.mu.Lock()
+ assert.False(t, att.sawPromptRetry, "sawPromptRetry should remain false for different SSID")
+ att.mu.Unlock()
+}
+
+func TestIWDBackend_ClassifyAttempt_BadCredentials_PromptRetry(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-5 * time.Second),
+ deadline: time.Now().Add(10 * time.Second),
+ sawPromptRetry: true,
+ }
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "bad-credentials", code)
+}
+
+func TestIWDBackend_ClassifyAttempt_DhcpTimeout(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-13 * time.Second),
+ deadline: time.Now().Add(2 * time.Second),
+ sawAuthish: true,
+ sawIPConfig: false,
+ }
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "dhcp-timeout", code)
+}
+
+func TestIWDBackend_ClassifyAttempt_AssocTimeout(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-5 * time.Second),
+ deadline: time.Now().Add(10 * time.Second),
+ }
+
+ backend.recentScansMu.Lock()
+ backend.recentScans["TestNetwork"] = time.Now()
+ backend.recentScansMu.Unlock()
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "assoc-timeout", code)
+}
+
+func TestIWDBackend_ClassifyAttempt_NoSuchSSID(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now().Add(-5 * time.Second),
+ deadline: time.Now().Add(10 * time.Second),
+ }
+
+ code := backend.classifyAttempt(att)
+ assert.Equal(t, "no-such-ssid", code)
+}
+
+func TestIWDBackend_MapIwdDBusError(t *testing.T) {
+ backend, _ := NewIWDBackend()
+
+ testCases := []struct {
+ name string
+ expected string
+ }{
+ {"net.connman.iwd.Error.AlreadyConnected", "already-connected"},
+ {"net.connman.iwd.Error.AuthenticationFailed", "bad-credentials"},
+ {"net.connman.iwd.Error.InvalidKey", "bad-credentials"},
+ {"net.connman.iwd.Error.IncorrectPassphrase", "bad-credentials"},
+ {"net.connman.iwd.Error.NotFound", "no-such-ssid"},
+ {"net.connman.iwd.Error.NotSupported", "connection-failed"},
+ {"net.connman.iwd.Agent.Error.Canceled", "user-canceled"},
+ {"net.connman.iwd.Error.Unknown", "connection-failed"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ code := backend.mapIwdDBusError(tc.name)
+ assert.Equal(t, tc.expected, code)
+ })
+ }
+}
+
+func TestConnectAttempt_Finalization(t *testing.T) {
+ backend, _ := NewIWDBackend()
+ backend.state = &BackendState{}
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.finalizeAttempt(att, "bad-credentials")
+
+ att.mu.Lock()
+ assert.True(t, att.finalized)
+ att.mu.Unlock()
+
+ backend.stateMutex.RLock()
+ assert.False(t, backend.state.IsConnecting)
+ assert.Empty(t, backend.state.ConnectingSSID)
+ assert.Equal(t, "bad-credentials", backend.state.LastError)
+ backend.stateMutex.RUnlock()
+}
+
+func TestConnectAttempt_DoubleFinalization(t *testing.T) {
+ backend, _ := NewIWDBackend()
+ backend.state = &BackendState{}
+
+ att := &connectAttempt{
+ ssid: "TestNetwork",
+ netPath: "/test",
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ backend.finalizeAttempt(att, "bad-credentials")
+ backend.finalizeAttempt(att, "dhcp-timeout")
+
+ backend.stateMutex.RLock()
+ assert.Equal(t, "bad-credentials", backend.state.LastError)
+ backend.stateMutex.RUnlock()
+}
diff --git a/backend/internal/server/network/backend_iwd_unimplemented.go b/backend/internal/server/network/backend_iwd_unimplemented.go
new file mode 100644
index 00000000..374d0bf5
--- /dev/null
+++ b/backend/internal/server/network/backend_iwd_unimplemented.go
@@ -0,0 +1,47 @@
+package network
+
+import "fmt"
+
+func (b *IWDBackend) GetWiredConnections() ([]WiredConnection, error) {
+ return nil, fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) {
+ return nil, fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) ConnectEthernet() error {
+ return fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) DisconnectEthernet() error {
+ return fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) ActivateWiredConnection(uuid string) error {
+ return fmt.Errorf("wired connections not supported by iwd")
+}
+
+func (b *IWDBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ return nil, fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ListActiveVPN() ([]VPNActive, error) {
+ return nil, fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) DisconnectVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) DisconnectAllVPN() error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
+
+func (b *IWDBackend) ClearVPNCredentials(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by iwd backend")
+}
diff --git a/backend/internal/server/network/backend_iwd_wifi.go b/backend/internal/server/network/backend_iwd_wifi.go
new file mode 100644
index 00000000..e2758b63
--- /dev/null
+++ b/backend/internal/server/network/backend_iwd_wifi.go
@@ -0,0 +1,662 @@
+package network
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *IWDBackend) updateState() error {
+ if b.devicePath == "" {
+ return nil
+ }
+
+ obj := b.conn.Object(iwdBusName, b.devicePath)
+
+ poweredVar, err := obj.GetProperty(iwdDeviceInterface + ".Powered")
+ if err == nil {
+ if powered, ok := poweredVar.Value().(bool); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = powered
+ b.stateMutex.Unlock()
+ }
+ }
+
+ if b.stationPath == "" {
+ return nil
+ }
+
+ stationObj := b.conn.Object(iwdBusName, b.stationPath)
+
+ stateVar, err := stationObj.GetProperty(iwdStationInterface + ".State")
+ if err == nil {
+ if state, ok := stateVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = (state == "connected")
+ if state == "connected" {
+ b.state.NetworkStatus = StatusWiFi
+ } else {
+ b.state.NetworkStatus = StatusDisconnected
+ }
+ b.stateMutex.Unlock()
+ }
+ }
+
+ connNetVar, err := stationObj.GetProperty(iwdStationInterface + ".ConnectedNetwork")
+ if err == nil && connNetVar.Value() != nil {
+ if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" {
+ netObj := b.conn.Object(iwdBusName, netPath)
+
+ nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name")
+ if err == nil {
+ if name, ok := nameVar.Value().(string); ok {
+ b.stateMutex.Lock()
+ b.state.WiFiSSID = name
+ b.stateMutex.Unlock()
+ }
+ }
+
+ var orderedNetworks [][]dbus.Variant
+ err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err == nil {
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+ currentNetPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok || currentNetPath != netPath {
+ continue
+ }
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+ b.stateMutex.Lock()
+ b.state.WiFiSignal = signal
+ b.stateMutex.Unlock()
+ break
+ }
+ }
+ }
+ }
+
+ networks, err := b.updateWiFiNetworks()
+ if err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) GetWiFiEnabled() (bool, error) {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return b.state.WiFiEnabled, nil
+}
+
+func (b *IWDBackend) SetWiFiEnabled(enabled bool) error {
+ if b.devicePath == "" {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.devicePath)
+ call := obj.Call(dbusPropertiesInterface+".Set", 0, iwdDeviceInterface, "Powered", dbus.MakeVariant(enabled))
+ if call.Err != nil {
+ return fmt.Errorf("failed to set WiFi enabled: %w", call.Err)
+ }
+
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = enabled
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) ScanWiFi() error {
+ if b.stationPath == "" {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.stationPath)
+
+ scanningVar, err := obj.GetProperty(iwdStationInterface + ".Scanning")
+ if err != nil {
+ return fmt.Errorf("failed to check scanning state: %w", err)
+ }
+
+ if scanning, ok := scanningVar.Value().(bool); ok && scanning {
+ return fmt.Errorf("scan already in progress")
+ }
+
+ call := obj.Call(iwdStationInterface+".Scan", 0)
+ if call.Err != nil {
+ return fmt.Errorf("scan request failed: %w", call.Err)
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
+ if b.stationPath == "" {
+ return nil, fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.stationPath)
+
+ var orderedNetworks [][]dbus.Variant
+ err := obj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get networks: %w", err)
+ }
+
+ knownNetworks, err := b.getKnownNetworks()
+ if err != nil {
+ knownNetworks = make(map[string]bool)
+ }
+
+ autoconnectMap, err := b.getAutoconnectSettings()
+ if err != nil {
+ autoconnectMap = make(map[string]bool)
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ wifiConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ networks := make([]WiFiNetwork, 0, len(orderedNetworks))
+ for _, netData := range orderedNetworks {
+ if len(netData) < 2 {
+ continue
+ }
+
+ networkPath, ok := netData[0].Value().(dbus.ObjectPath)
+ if !ok {
+ continue
+ }
+
+ signalStrength, ok := netData[1].Value().(int16)
+ if !ok {
+ continue
+ }
+
+ netObj := b.conn.Object(iwdBusName, networkPath)
+
+ nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name")
+ if err != nil {
+ continue
+ }
+ name, ok := nameVar.Value().(string)
+ if !ok {
+ continue
+ }
+
+ typeVar, err := netObj.GetProperty(iwdNetworkInterface + ".Type")
+ if err != nil {
+ continue
+ }
+ netType, ok := typeVar.Value().(string)
+ if !ok {
+ continue
+ }
+
+ signalDbm := signalStrength / 100
+ signal := uint8(signalDbm + 100)
+ if signalDbm > 0 {
+ signal = 100
+ } else if signalDbm < -100 {
+ signal = 0
+ }
+
+ secured := netType != "open"
+
+ network := WiFiNetwork{
+ SSID: name,
+ Signal: signal,
+ Secured: secured,
+ Connected: wifiConnected && name == currentSSID,
+ Saved: knownNetworks[name],
+ Autoconnect: autoconnectMap[name],
+ Enterprise: netType == "8021x",
+ }
+
+ networks = append(networks, network)
+ }
+
+ sortWiFiNetworks(networks)
+
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+
+ now := time.Now()
+ b.recentScansMu.Lock()
+ for _, net := range networks {
+ b.recentScans[net.SSID] = now
+ }
+ b.recentScansMu.Unlock()
+
+ return networks, nil
+}
+
+func (b *IWDBackend) getKnownNetworks() (map[string]bool, error) {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return nil, err
+ }
+
+ known := make(map[string]bool)
+ for _, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok {
+ known[name] = true
+ }
+ }
+ }
+ }
+
+ return known, nil
+}
+
+func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return nil, err
+ }
+
+ autoconnectMap := make(map[string]bool)
+ for _, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok {
+ autoconnect := true
+ if acVar, ok := knownProps["AutoConnect"]; ok {
+ if ac, ok := acVar.Value().(bool); ok {
+ autoconnect = ac
+ }
+ }
+ autoconnectMap[name] = autoconnect
+ }
+ }
+ }
+ }
+
+ return autoconnectMap, nil
+}
+
+func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ b.stateMutex.RLock()
+ networks := b.state.WiFiNetworks
+ b.stateMutex.RUnlock()
+
+ var found *WiFiNetwork
+ for i := range networks {
+ if networks[i].SSID == ssid {
+ found = &networks[i]
+ break
+ }
+ }
+
+ if found == nil {
+ return nil, fmt.Errorf("network not found: %s", ssid)
+ }
+
+ return &NetworkInfoResponse{
+ SSID: ssid,
+ Bands: []WiFiNetwork{*found},
+ }, nil
+}
+
+func (b *IWDBackend) setConnectError(code string) {
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = code
+ b.stateMutex.Unlock()
+}
+
+func (b *IWDBackend) seenInRecentScan(ssid string) bool {
+ b.recentScansMu.Lock()
+ defer b.recentScansMu.Unlock()
+ lastSeen, ok := b.recentScans[ssid]
+ return ok && time.Since(lastSeen) < 30*time.Second
+}
+
+func (b *IWDBackend) classifyAttempt(att *connectAttempt) string {
+ att.mu.Lock()
+ defer att.mu.Unlock()
+
+ if att.sawPromptRetry {
+ return errdefs.ErrBadCredentials
+ }
+
+ if !att.connectedAt.IsZero() && !att.sawIPConfig {
+ connDuration := time.Since(att.connectedAt)
+ if connDuration > 500*time.Millisecond && connDuration < 3*time.Second {
+ return errdefs.ErrBadCredentials
+ }
+ }
+
+ if (att.sawAuthish || !att.connectedAt.IsZero()) && !att.sawIPConfig {
+ if time.Since(att.start) > 12*time.Second {
+ return errdefs.ErrDhcpTimeout
+ }
+ }
+
+ if !att.sawAuthish && att.connectedAt.IsZero() {
+ if !b.seenInRecentScan(att.ssid) {
+ return errdefs.ErrNoSuchSSID
+ }
+ return errdefs.ErrAssocTimeout
+ }
+
+ return errdefs.ErrAssocTimeout
+}
+
+func (b *IWDBackend) finalizeAttempt(att *connectAttempt, code string) {
+ att.mu.Lock()
+ if att.finalized {
+ att.mu.Unlock()
+ return
+ }
+ att.finalized = true
+ att.mu.Unlock()
+
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = code
+ b.stateMutex.Unlock()
+
+ b.updateState()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+}
+
+func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) {
+ b.sigWG.Add(1)
+ go func() {
+ defer b.sigWG.Done()
+
+ ticker := time.NewTicker(250 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ att.mu.Lock()
+ finalized := att.finalized
+ att.mu.Unlock()
+
+ if finalized || time.Now().After(att.deadline) {
+ if !finalized {
+ b.finalizeAttempt(att, b.classifyAttempt(att))
+ }
+ return
+ }
+
+ station := b.conn.Object(iwdBusName, b.stationPath)
+ stVar, err := station.GetProperty(iwdStationInterface + ".State")
+ if err != nil {
+ continue
+ }
+ state, _ := stVar.Value().(string)
+
+ cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork")
+ if err != nil {
+ continue
+ }
+ var connPath dbus.ObjectPath
+ if cnVar.Value() != nil {
+ connPath, _ = cnVar.Value().(dbus.ObjectPath)
+ }
+
+ att.mu.Lock()
+ if connPath == att.netPath && state == "connected" && att.connectedAt.IsZero() {
+ att.connectedAt = time.Now()
+ }
+ if state == "configuring" {
+ att.sawIPConfig = true
+ }
+ att.mu.Unlock()
+
+ case <-b.stopChan:
+ return
+ }
+ }
+ }()
+}
+
+func (b *IWDBackend) mapIwdDBusError(name string) string {
+ switch name {
+ case "net.connman.iwd.Error.AlreadyConnected":
+ return errdefs.ErrAlreadyConnected
+ case "net.connman.iwd.Error.AuthenticationFailed",
+ "net.connman.iwd.Error.InvalidKey",
+ "net.connman.iwd.Error.IncorrectPassphrase":
+ return errdefs.ErrBadCredentials
+ case "net.connman.iwd.Error.NotFound":
+ return errdefs.ErrNoSuchSSID
+ case "net.connman.iwd.Error.NotSupported":
+ return errdefs.ErrConnectionFailed
+ case "net.connman.iwd.Agent.Error.Canceled":
+ return errdefs.ErrUserCanceled
+ default:
+ return errdefs.ErrConnectionFailed
+ }
+}
+
+func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
+ if b.stationPath == "" {
+ b.setConnectError(errdefs.ErrWifiDisabled)
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ networkPath, err := b.findNetworkPath(req.SSID)
+ if err != nil {
+ b.setConnectError(errdefs.ErrNoSuchSSID)
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return fmt.Errorf("network not found: %w", err)
+ }
+
+ att := &connectAttempt{
+ ssid: req.SSID,
+ netPath: networkPath,
+ start: time.Now(),
+ deadline: time.Now().Add(15 * time.Second),
+ }
+
+ b.attemptMutex.Lock()
+ b.curAttempt = att
+ b.attemptMutex.Unlock()
+
+ b.stateMutex.Lock()
+ b.state.IsConnecting = true
+ b.state.ConnectingSSID = req.SSID
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ netObj := b.conn.Object(iwdBusName, networkPath)
+ go func() {
+ call := netObj.Call(iwdNetworkInterface+".Connect", 0)
+ if call.Err != nil {
+ var code string
+ if dbusErr, ok := call.Err.(dbus.Error); ok {
+ code = b.mapIwdDBusError(dbusErr.Name)
+ } else if dbusErrPtr, ok := call.Err.(*dbus.Error); ok {
+ code = b.mapIwdDBusError(dbusErrPtr.Name)
+ } else {
+ code = errdefs.ErrConnectionFailed
+ }
+
+ att.mu.Lock()
+ if att.sawPromptRetry {
+ code = errdefs.ErrBadCredentials
+ }
+ att.mu.Unlock()
+
+ b.finalizeAttempt(att, code)
+ return
+ }
+
+ b.startAttemptWatchdog(att)
+ }()
+
+ return nil
+}
+
+func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, error) {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return "", err
+ }
+
+ for path, interfaces := range objects {
+ if netProps, ok := interfaces[iwdNetworkInterface]; ok {
+ if nameVar, ok := netProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok && name == ssid {
+ return path, nil
+ }
+ }
+ }
+ }
+
+ return "", fmt.Errorf("network not found")
+}
+
+func (b *IWDBackend) DisconnectWiFi() error {
+ if b.stationPath == "" {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ obj := b.conn.Object(iwdBusName, b.stationPath)
+ call := obj.Call(iwdStationInterface+".Disconnect", 0)
+ if call.Err != nil {
+ return fmt.Errorf("failed to disconnect: %w", call.Err)
+ }
+
+ b.updateState()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ isConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return err
+ }
+
+ for path, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok && name == ssid {
+ knownObj := b.conn.Object(iwdBusName, path)
+ call := knownObj.Call(iwdKnownNetworkInterface+".Forget", 0)
+ if call.Err != nil {
+ return fmt.Errorf("failed to forget network: %w", call.Err)
+ }
+
+ if isConnected && currentSSID == ssid {
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = false
+ b.state.WiFiSSID = ""
+ b.state.WiFiSignal = 0
+ b.state.WiFiIP = ""
+ b.state.NetworkStatus = StatusDisconnected
+ b.stateMutex.Unlock()
+ }
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+ }
+ }
+ }
+
+ return fmt.Errorf("network not found")
+}
+
+func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ obj := b.conn.Object(iwdBusName, iwdObjectPath)
+
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return err
+ }
+
+ for path, interfaces := range objects {
+ if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
+ if nameVar, ok := knownProps["Name"]; ok {
+ if name, ok := nameVar.Value().(string); ok && name == ssid {
+ knownObj := b.conn.Object(iwdBusName, path)
+ call := knownObj.Call(dbusPropertiesInterface+".Set", 0, iwdKnownNetworkInterface, "AutoConnect", dbus.MakeVariant(autoconnect))
+ if call.Err != nil {
+ return fmt.Errorf("failed to set autoconnect: %w", call.Err)
+ }
+
+ b.updateState()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+ }
+ }
+ }
+
+ return fmt.Errorf("network not found")
+}
diff --git a/backend/internal/server/network/backend_networkd.go b/backend/internal/server/network/backend_networkd.go
new file mode 100644
index 00000000..a4918966
--- /dev/null
+++ b/backend/internal/server/network/backend_networkd.go
@@ -0,0 +1,268 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ networkdBusName = "org.freedesktop.network1"
+ networkdManagerPath = "/org/freedesktop/network1"
+ networkdManagerIface = "org.freedesktop.network1.Manager"
+ networkdLinkIface = "org.freedesktop.network1.Link"
+)
+
+type linkInfo struct {
+ ifindex int32
+ name string
+ path dbus.ObjectPath
+ opState string
+}
+
+type SystemdNetworkdBackend struct {
+ conn *dbus.Conn
+ managerPath dbus.ObjectPath
+ links map[string]*linkInfo
+ linksMutex sync.RWMutex
+ state *BackendState
+ stateMutex sync.RWMutex
+ onStateChange func()
+ stopChan chan struct{}
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+}
+
+func NewSystemdNetworkdBackend() (*SystemdNetworkdBackend, error) {
+ return &SystemdNetworkdBackend{
+ managerPath: networkdManagerPath,
+ links: make(map[string]*linkInfo),
+ state: &BackendState{
+ Backend: "networkd",
+ WiFiNetworks: []WiFiNetwork{},
+ },
+ stopChan: make(chan struct{}),
+ }, nil
+}
+
+func (b *SystemdNetworkdBackend) Initialize() error {
+ c, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("connect bus: %w", err)
+ }
+ b.conn = c
+
+ if err := b.enumerateLinks(); err != nil {
+ c.Close()
+ return fmt.Errorf("enumerate links: %w", err)
+ }
+
+ if err := b.updateState(); err != nil {
+ c.Close()
+ return fmt.Errorf("update initial state: %w", err)
+ }
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) Close() {
+ close(b.stopChan)
+ b.StopMonitoring()
+
+ if b.conn != nil {
+ b.conn.Close()
+ }
+}
+
+func (b *SystemdNetworkdBackend) enumerateLinks() error {
+ obj := b.conn.Object(networkdBusName, b.managerPath)
+
+ var links []struct {
+ Ifindex int32
+ Name string
+ Path dbus.ObjectPath
+ }
+ err := obj.Call(networkdManagerIface+".ListLinks", 0).Store(&links)
+ if err != nil {
+ return fmt.Errorf("ListLinks: %w", err)
+ }
+
+ b.linksMutex.Lock()
+ defer b.linksMutex.Unlock()
+
+ for _, l := range links {
+ b.links[l.Name] = &linkInfo{
+ ifindex: l.Ifindex,
+ name: l.Name,
+ path: l.Path,
+ }
+ log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s)", l.Name, l.Ifindex, l.Path)
+ }
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) updateState() error {
+ b.linksMutex.RLock()
+ defer b.linksMutex.RUnlock()
+
+ var wiredIface *linkInfo
+ var wifiIface *linkInfo
+
+ for name, link := range b.links {
+ if b.isVirtualInterface(name) {
+ continue
+ }
+
+ linkObj := b.conn.Object(networkdBusName, link.path)
+ opStateVar, err := linkObj.GetProperty(networkdLinkIface + ".OperationalState")
+ if err == nil {
+ if opState, ok := opStateVar.Value().(string); ok {
+ link.opState = opState
+ }
+ }
+
+ if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" {
+ wifiIface = link
+ }
+ } else if !b.isVirtualInterface(name) {
+ if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" {
+ wiredIface = link
+ }
+ }
+ }
+
+ var wiredConns []WiredConnection
+ for name, link := range b.links {
+ if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ continue
+ }
+
+ active := link.opState == "routable" || link.opState == "carrier"
+ wiredConns = append(wiredConns, WiredConnection{
+ Path: link.path,
+ ID: name,
+ UUID: "wired:" + name,
+ Type: "ethernet",
+ IsActive: active,
+ })
+ }
+
+ b.stateMutex.Lock()
+ defer b.stateMutex.Unlock()
+
+ b.state.NetworkStatus = StatusDisconnected
+ b.state.EthernetConnected = false
+ b.state.EthernetIP = ""
+ b.state.WiFiConnected = false
+ b.state.WiFiIP = ""
+ b.state.WiredConnections = wiredConns
+
+ if wiredIface != nil {
+ b.state.EthernetDevice = wiredIface.name
+ log.Debugf("networkd: wired interface %s opState=%s", wiredIface.name, wiredIface.opState)
+ if wiredIface.opState == "routable" || wiredIface.opState == "carrier" {
+ b.state.EthernetConnected = true
+ b.state.NetworkStatus = StatusEthernet
+
+ if addrs := b.getAddresses(wiredIface.name); len(addrs) > 0 {
+ b.state.EthernetIP = addrs[0]
+ log.Debugf("networkd: ethernet IP %s on %s", addrs[0], wiredIface.name)
+ }
+ }
+ }
+
+ if wifiIface != nil {
+ b.state.WiFiDevice = wifiIface.name
+ log.Debugf("networkd: wifi interface %s opState=%s", wifiIface.name, wifiIface.opState)
+ if wifiIface.opState == "routable" || wifiIface.opState == "carrier" {
+ b.state.WiFiConnected = true
+
+ if addrs := b.getAddresses(wifiIface.name); len(addrs) > 0 {
+ b.state.WiFiIP = addrs[0]
+ log.Debugf("networkd: wifi IP %s on %s", addrs[0], wifiIface.name)
+ if b.state.NetworkStatus == StatusDisconnected {
+ b.state.NetworkStatus = StatusWiFi
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) isVirtualInterface(name string) bool {
+ virtualPrefixes := []string{
+ "lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
+ "vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
+ }
+ for _, prefix := range virtualPrefixes {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string {
+ iface, err := net.InterfaceByName(ifname)
+ if err != nil {
+ return nil
+ }
+
+ addrs, err := iface.Addrs()
+ if err != nil {
+ return nil
+ }
+
+ var result []string
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok {
+ if ipv4 := ipnet.IP.To4(); ipv4 != nil {
+ result = append(result, ipv4.String())
+ }
+ }
+ }
+ return result
+}
+
+func (b *SystemdNetworkdBackend) GetCurrentState() (*BackendState, error) {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ s := *b.state
+ return &s, nil
+}
+
+func (b *SystemdNetworkdBackend) GetPromptBroker() PromptBroker {
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) SetPromptBroker(broker PromptBroker) error {
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ return fmt.Errorf("credentials not needed by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) CancelCredentials(token string) error {
+ return fmt.Errorf("credentials not needed by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) EnsureDhcpUp(ifname string) error {
+ b.linksMutex.RLock()
+ link, exists := b.links[ifname]
+ b.linksMutex.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("interface %s not found", ifname)
+ }
+
+ linkObj := b.conn.Object(networkdBusName, link.path)
+ return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err
+}
diff --git a/backend/internal/server/network/backend_networkd_ethernet.go b/backend/internal/server/network/backend_networkd_ethernet.go
new file mode 100644
index 00000000..d0c9b948
--- /dev/null
+++ b/backend/internal/server/network/backend_networkd_ethernet.go
@@ -0,0 +1,110 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+func (b *SystemdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error) {
+ b.linksMutex.RLock()
+ defer b.linksMutex.RUnlock()
+
+ var conns []WiredConnection
+ for name, link := range b.links {
+ if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ continue
+ }
+
+ active := link.opState == "routable" || link.opState == "carrier"
+ conns = append(conns, WiredConnection{
+ Path: link.path,
+ ID: name,
+ UUID: "wired:" + name,
+ Type: "ethernet",
+ IsActive: active,
+ })
+ }
+
+ return conns, nil
+}
+
+func (b *SystemdNetworkdBackend) GetWiredNetworkDetails(id string) (*WiredNetworkInfoResponse, error) {
+ ifname := strings.TrimPrefix(id, "wired:")
+
+ b.linksMutex.RLock()
+ _, exists := b.links[ifname]
+ b.linksMutex.RUnlock()
+
+ if !exists {
+ return nil, fmt.Errorf("interface %s not found", ifname)
+ }
+
+ iface, err := net.InterfaceByName(ifname)
+ if err != nil {
+ return nil, fmt.Errorf("get interface: %w", err)
+ }
+
+ addrs, _ := iface.Addrs()
+ var ipv4s, ipv6s []string
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok {
+ if ipv4 := ipnet.IP.To4(); ipv4 != nil {
+ ipv4s = append(ipv4s, ipnet.String())
+ } else if ipv6 := ipnet.IP.To16(); ipv6 != nil {
+ ipv6s = append(ipv6s, ipnet.String())
+ }
+ }
+ }
+
+ return &WiredNetworkInfoResponse{
+ UUID: id,
+ IFace: ifname,
+ HwAddr: iface.HardwareAddr.String(),
+ IPv4: WiredIPConfig{
+ IPs: ipv4s,
+ },
+ IPv6: WiredIPConfig{
+ IPs: ipv6s,
+ },
+ }, nil
+}
+
+func (b *SystemdNetworkdBackend) ConnectEthernet() error {
+ b.linksMutex.RLock()
+ var primaryWired *linkInfo
+ for name, l := range b.links {
+ if strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
+ continue
+ }
+ primaryWired = l
+ break
+ }
+ b.linksMutex.RUnlock()
+
+ if primaryWired == nil {
+ return fmt.Errorf("no wired interface found")
+ }
+
+ linkObj := b.conn.Object(networkdBusName, primaryWired.path)
+ return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err
+}
+
+func (b *SystemdNetworkdBackend) DisconnectEthernet() error {
+ return fmt.Errorf("not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ActivateWiredConnection(id string) error {
+ ifname := strings.TrimPrefix(id, "wired:")
+
+ b.linksMutex.RLock()
+ link, exists := b.links[ifname]
+ b.linksMutex.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("interface %s not found", ifname)
+ }
+
+ linkObj := b.conn.Object(networkdBusName, link.path)
+ return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err
+}
diff --git a/backend/internal/server/network/backend_networkd_signals.go b/backend/internal/server/network/backend_networkd_signals.go
new file mode 100644
index 00000000..3ed3df9f
--- /dev/null
+++ b/backend/internal/server/network/backend_networkd_signals.go
@@ -0,0 +1,68 @@
+package network
+
+import (
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *SystemdNetworkdBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ b.signals = make(chan *dbus.Signal, 64)
+ b.conn.Signal(b.signals)
+
+ matchRules := []string{
+ "type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/freedesktop/network1'",
+ "type='signal',interface='org.freedesktop.network1.Manager'",
+ }
+
+ for _, rule := range matchRules {
+ if err := b.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Err; err != nil {
+ return fmt.Errorf("add match %q: %w", rule, err)
+ }
+ }
+
+ b.sigWG.Add(1)
+ go b.signalLoop()
+
+ return nil
+}
+
+func (b *SystemdNetworkdBackend) StopMonitoring() {
+ b.sigWG.Wait()
+}
+
+func (b *SystemdNetworkdBackend) signalLoop() {
+ defer b.sigWG.Done()
+
+ for {
+ select {
+ case <-b.stopChan:
+ return
+ case sig := <-b.signals:
+ if sig == nil {
+ continue
+ }
+
+ if sig.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" {
+ if len(sig.Body) < 2 {
+ continue
+ }
+ iface, ok := sig.Body[0].(string)
+ if !ok || iface != networkdLinkIface {
+ continue
+ }
+
+ b.enumerateLinks()
+ if err := b.updateState(); err != nil {
+ log.Warnf("networkd state update failed: %v", err)
+ }
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+ }
+ }
+}
diff --git a/backend/internal/server/network/backend_networkd_test.go b/backend/internal/server/network/backend_networkd_test.go
new file mode 100644
index 00000000..383c775c
--- /dev/null
+++ b/backend/internal/server/network/backend_networkd_test.go
@@ -0,0 +1,125 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSystemdNetworkdBackend_New(t *testing.T) {
+ backend, err := NewSystemdNetworkdBackend()
+ assert.NoError(t, err)
+ assert.NotNil(t, backend)
+ assert.Equal(t, "networkd", backend.state.Backend)
+ assert.NotNil(t, backend.links)
+ assert.NotNil(t, backend.stopChan)
+}
+
+func TestSystemdNetworkdBackend_GetCurrentState(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+ backend.state.NetworkStatus = StatusEthernet
+ backend.state.EthernetConnected = true
+ backend.state.EthernetIP = "192.168.1.100"
+
+ state, err := backend.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, StatusEthernet, state.NetworkStatus)
+ assert.True(t, state.EthernetConnected)
+ assert.Equal(t, "192.168.1.100", state.EthernetIP)
+}
+
+func TestSystemdNetworkdBackend_WiFiNotSupported(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ err := backend.ScanWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ req := ConnectionRequest{SSID: "test"}
+ err = backend.ConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.DisconnectWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.ForgetWiFiNetwork("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ _, err = backend.GetWiFiNetworkDetails("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestSystemdNetworkdBackend_VPNNotSupported(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ profiles, err := backend.ListVPNProfiles()
+ assert.NoError(t, err)
+ assert.Empty(t, profiles)
+
+ active, err := backend.ListActiveVPN()
+ assert.NoError(t, err)
+ assert.Empty(t, active)
+
+ err = backend.ConnectVPN("test", false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.DisconnectVPN("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.DisconnectAllVPN()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+
+ err = backend.ClearVPNCredentials("test")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestSystemdNetworkdBackend_PromptBroker(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ broker := backend.GetPromptBroker()
+ assert.Nil(t, broker)
+
+ err := backend.SetPromptBroker(nil)
+ assert.NoError(t, err)
+
+ err = backend.SubmitCredentials("token", nil, false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not needed")
+
+ err = backend.CancelCredentials("token")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not needed")
+}
+
+func TestSystemdNetworkdBackend_GetWiFiEnabled(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ enabled, err := backend.GetWiFiEnabled()
+ assert.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+func TestSystemdNetworkdBackend_SetWiFiEnabled(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ err := backend.SetWiFiEnabled(false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
+
+func TestSystemdNetworkdBackend_DisconnectEthernet(t *testing.T) {
+ backend, _ := NewSystemdNetworkdBackend()
+
+ err := backend.DisconnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported")
+}
diff --git a/backend/internal/server/network/backend_networkd_unimplemented.go b/backend/internal/server/network/backend_networkd_unimplemented.go
new file mode 100644
index 00000000..1f44752f
--- /dev/null
+++ b/backend/internal/server/network/backend_networkd_unimplemented.go
@@ -0,0 +1,59 @@
+package network
+
+import "fmt"
+
+func (b *SystemdNetworkdBackend) GetWiFiEnabled() (bool, error) {
+ return true, nil
+}
+
+func (b *SystemdNetworkdBackend) SetWiFiEnabled(enabled bool) error {
+ return fmt.Errorf("WiFi control not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ScanWiFi() error {
+ return fmt.Errorf("WiFi scan not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ return nil, fmt.Errorf("WiFi details not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error {
+ return fmt.Errorf("WiFi connect not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DisconnectWiFi() error {
+ return fmt.Errorf("WiFi disconnect not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ForgetWiFiNetwork(ssid string) error {
+ return fmt.Errorf("WiFi forget not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ return []VPNProfile{}, nil
+}
+
+func (b *SystemdNetworkdBackend) ListActiveVPN() ([]VPNActive, error) {
+ return []VPNActive{}, nil
+}
+
+func (b *SystemdNetworkdBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DisconnectVPN(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) DisconnectAllVPN() error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error {
+ return fmt.Errorf("VPN not supported by networkd backend")
+}
+
+func (b *SystemdNetworkdBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ return fmt.Errorf("WiFi autoconnect not supported by networkd backend")
+}
diff --git a/backend/internal/server/network/backend_networkmanager.go b/backend/internal/server/network/backend_networkmanager.go
new file mode 100644
index 00000000..7c9d50c7
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager.go
@@ -0,0 +1,307 @@
+package network
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ dbusNMPath = "/org/freedesktop/NetworkManager"
+ dbusNMInterface = "org.freedesktop.NetworkManager"
+ dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device"
+ dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless"
+ dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint"
+ dbusPropsInterface = "org.freedesktop.DBus.Properties"
+
+ NmDeviceStateReasonWrongPassword = 8
+ NmDeviceStateReasonSupplicantTimeout = 24
+ NmDeviceStateReasonSupplicantFailed = 25
+ NmDeviceStateReasonSecretsRequired = 7
+ NmDeviceStateReasonNoSecrets = 6
+ NmDeviceStateReasonNoSsid = 10
+ NmDeviceStateReasonDhcpClientFailed = 14
+ NmDeviceStateReasonIpConfigUnavailable = 18
+ NmDeviceStateReasonSupplicantDisconnect = 23
+ NmDeviceStateReasonCarrier = 40
+ NmDeviceStateReasonNewActivation = 60
+)
+
+type NetworkManagerBackend struct {
+ nmConn interface{}
+ ethernetDevice interface{}
+ wifiDevice interface{}
+ settings interface{}
+ wifiDev interface{}
+
+ dbusConn *dbus.Conn
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ stopChan chan struct{}
+
+ secretAgent *SecretAgent
+ promptBroker PromptBroker
+
+ state *BackendState
+ stateMutex sync.RWMutex
+
+ lastFailedSSID string
+ lastFailedTime int64
+ failedMutex sync.RWMutex
+
+ onStateChange func()
+}
+
+func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) {
+ var nm gonetworkmanager.NetworkManager
+ var err error
+
+ if len(nmConn) > 0 && nmConn[0] != nil {
+ // Use injected connection (for testing)
+ nm = nmConn[0]
+ } else {
+ // Create real connection
+ nm, err = gonetworkmanager.NewNetworkManager()
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to NetworkManager: %w", err)
+ }
+ }
+
+ backend := &NetworkManagerBackend{
+ nmConn: nm,
+ stopChan: make(chan struct{}),
+ state: &BackendState{
+ Backend: "networkmanager",
+ },
+ }
+
+ return backend, nil
+}
+
+func (b *NetworkManagerBackend) Initialize() error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ if s, err := gonetworkmanager.NewSettings(); err == nil {
+ b.settings = s
+ }
+
+ devices, err := nm.GetDevices()
+ if err != nil {
+ return fmt.Errorf("failed to get devices: %w", err)
+ }
+
+ for _, dev := range devices {
+ devType, err := dev.GetPropertyDeviceType()
+ if err != nil {
+ continue
+ }
+
+ switch devType {
+ case gonetworkmanager.NmDeviceTypeEthernet:
+ if managed, _ := dev.GetPropertyManaged(); !managed {
+ continue
+ }
+ b.ethernetDevice = dev
+ if err := b.updateEthernetState(); err != nil {
+ continue
+ }
+ _, err := b.listEthernetConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get wired configurations: %w", err)
+ }
+
+ case gonetworkmanager.NmDeviceTypeWifi:
+ b.wifiDevice = dev
+ if w, err := gonetworkmanager.NewDeviceWireless(dev.GetPath()); err == nil {
+ b.wifiDev = w
+ }
+ wifiEnabled, err := nm.GetPropertyWirelessEnabled()
+ if err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = wifiEnabled
+ b.stateMutex.Unlock()
+ }
+ if err := b.updateWiFiState(); err != nil {
+ continue
+ }
+ if wifiEnabled {
+ if _, err := b.updateWiFiNetworks(); err != nil {
+ log.Warnf("Failed to get initial networks: %v", err)
+ }
+ }
+ }
+ }
+
+ if err := b.updatePrimaryConnection(); err != nil {
+ return err
+ }
+
+ if _, err := b.ListVPNProfiles(); err != nil {
+ log.Warnf("Failed to get initial VPN profiles: %v", err)
+ }
+
+ if _, err := b.ListActiveVPN(); err != nil {
+ log.Warnf("Failed to get initial active VPNs: %v", err)
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) Close() {
+ close(b.stopChan)
+ b.StopMonitoring()
+
+ if b.secretAgent != nil {
+ b.secretAgent.Close()
+ }
+}
+
+func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+
+ state := *b.state
+ state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
+ state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
+ state.VPNProfiles = append([]VPNProfile(nil), b.state.VPNProfiles...)
+ state.VPNActive = append([]VPNActive(nil), b.state.VPNActive...)
+
+ return &state, nil
+}
+
+func (b *NetworkManagerBackend) StartMonitoring(onStateChange func()) error {
+ b.onStateChange = onStateChange
+
+ if err := b.startSecretAgent(); err != nil {
+ return fmt.Errorf("failed to start secret agent: %w", err)
+ }
+
+ if err := b.startSignalPump(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) StopMonitoring() {
+ b.stopSignalPump()
+}
+
+func (b *NetworkManagerBackend) GetPromptBroker() PromptBroker {
+ return b.promptBroker
+}
+
+func (b *NetworkManagerBackend) SetPromptBroker(broker PromptBroker) error {
+ if broker == nil {
+ return fmt.Errorf("broker cannot be nil")
+ }
+
+ hadAgent := b.secretAgent != nil
+
+ b.promptBroker = broker
+
+ if b.secretAgent != nil {
+ b.secretAgent.Close()
+ b.secretAgent = nil
+ }
+
+ if hadAgent {
+ return b.startSecretAgent()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Secrets: secrets,
+ Save: save,
+ Cancel: false,
+ })
+}
+
+func (b *NetworkManagerBackend) CancelCredentials(token string) error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return b.promptBroker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+}
+
+func (b *NetworkManagerBackend) ensureWiFiDevice() error {
+ if b.wifiDev != nil {
+ return nil
+ }
+
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+ wifiDev, err := gonetworkmanager.NewDeviceWireless(dev.GetPath())
+ if err != nil {
+ return fmt.Errorf("failed to get wireless device: %w", err)
+ }
+ b.wifiDev = wifiDev
+ return nil
+}
+
+func (b *NetworkManagerBackend) startSecretAgent() error {
+ if b.promptBroker == nil {
+ return fmt.Errorf("prompt broker not set")
+ }
+
+ agent, err := NewSecretAgent(b.promptBroker, nil, b)
+ if err != nil {
+ return err
+ }
+
+ b.secretAgent = agent
+ return nil
+}
+
+func (b *NetworkManagerBackend) getActiveConnections() (map[string]bool, error) {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeUUIDs := make(map[string]bool)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return activeUUIDs, fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "802-3-ethernet" {
+ continue
+ }
+
+ state, err := activeConn.GetPropertyState()
+ if err != nil {
+ continue
+ }
+ if state < 1 || state > 2 {
+ continue
+ }
+
+ uuid, err := activeConn.GetPropertyUUID()
+ if err != nil {
+ continue
+ }
+ activeUUIDs[uuid] = true
+ }
+ return activeUUIDs, nil
+}
diff --git a/backend/internal/server/network/backend_networkmanager_ethernet.go b/backend/internal/server/network/backend_networkmanager_ethernet.go
new file mode 100644
index 00000000..33d3f46a
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_ethernet.go
@@ -0,0 +1,317 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (b *NetworkManagerBackend) GetWiredConnections() ([]WiredConnection, error) {
+ return b.listEthernetConnections()
+}
+
+func (b *NetworkManagerBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) {
+ if b.ethernetDevice == nil {
+ return nil, fmt.Errorf("no ethernet device available")
+ }
+
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ iface, _ := dev.GetPropertyInterface()
+ driver, _ := dev.GetPropertyDriver()
+
+ hwAddr := "Not available"
+ var speed uint32 = 0
+ wiredDevice, err := gonetworkmanager.NewDeviceWired(dev.GetPath())
+ if err == nil {
+ hwAddr, _ = wiredDevice.GetPropertyHwAddress()
+ speed, _ = wiredDevice.GetPropertySpeed()
+ }
+ var ipv4Config WiredIPConfig
+ var ipv6Config WiredIPConfig
+
+ activeConn, err := dev.GetPropertyActiveConnection()
+ if err == nil && activeConn != nil {
+ ip4Config, err := activeConn.GetPropertyIP4Config()
+ if err == nil && ip4Config != nil {
+ var ips []string
+ addresses, err := ip4Config.GetPropertyAddressData()
+ if err == nil && len(addresses) > 0 {
+ for _, addr := range addresses {
+ ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix))))
+ }
+ }
+
+ gateway, _ := ip4Config.GetPropertyGateway()
+ dnsAddrs := ""
+ dns, err := ip4Config.GetPropertyNameserverData()
+ if err == nil && len(dns) > 0 {
+ for _, d := range dns {
+ if len(dnsAddrs) > 0 {
+ dnsAddrs = strings.Join([]string{dnsAddrs, d.Address}, "; ")
+ } else {
+ dnsAddrs = d.Address
+ }
+ }
+ }
+
+ ipv4Config = WiredIPConfig{
+ IPs: ips,
+ Gateway: gateway,
+ DNS: dnsAddrs,
+ }
+ }
+
+ ip6Config, err := activeConn.GetPropertyIP6Config()
+ if err == nil && ip6Config != nil {
+ var ips []string
+ addresses, err := ip6Config.GetPropertyAddressData()
+ if err == nil && len(addresses) > 0 {
+ for _, addr := range addresses {
+ ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix))))
+ }
+ }
+
+ gateway, _ := ip6Config.GetPropertyGateway()
+ dnsAddrs := ""
+ dns, err := ip6Config.GetPropertyNameservers()
+ if err == nil && len(dns) > 0 {
+ for _, d := range dns {
+ if len(d) == 16 {
+ ip := net.IP(d)
+ if len(dnsAddrs) > 0 {
+ dnsAddrs = strings.Join([]string{dnsAddrs, ip.String()}, "; ")
+ } else {
+ dnsAddrs = ip.String()
+ }
+ }
+ }
+ }
+
+ ipv6Config = WiredIPConfig{
+ IPs: ips,
+ Gateway: gateway,
+ DNS: dnsAddrs,
+ }
+ }
+ }
+
+ return &WiredNetworkInfoResponse{
+ UUID: uuid,
+ IFace: iface,
+ Driver: driver,
+ HwAddr: hwAddr,
+ Speed: strconv.Itoa(int(speed)),
+ IPv4: ipv4Config,
+ IPv6: ipv6Config,
+ }, nil
+}
+
+func (b *NetworkManagerBackend) ConnectEthernet() error {
+ if b.ethernetDevice == nil {
+ return fmt.Errorf("no ethernet device available")
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ settingsMgr, err := gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if connType, ok := connMeta["type"].(string); ok && connType == "802-3-ethernet" {
+ _, err := nm.ActivateConnection(conn, dev, nil)
+ if err != nil {
+ return fmt.Errorf("failed to activate ethernet: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+ }
+ }
+ }
+
+ settings := make(map[string]map[string]interface{})
+ settings["connection"] = map[string]interface{}{
+ "id": "Wired connection",
+ "type": "802-3-ethernet",
+ }
+
+ _, err = nm.AddAndActivateConnection(settings, dev)
+ if err != nil {
+ return fmt.Errorf("failed to create and activate ethernet: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) DisconnectEthernet() error {
+ if b.ethernetDevice == nil {
+ return fmt.Errorf("no ethernet device available")
+ }
+
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ err := dev.Disconnect()
+ if err != nil {
+ return fmt.Errorf("failed to disconnect: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) ActivateWiredConnection(uuid string) error {
+ if b.ethernetDevice == nil {
+ return fmt.Errorf("no ethernet device available")
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ settingsMgr, err := gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ var targetConnection gonetworkmanager.Connection
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connectionSettings, ok := settings["connection"]; ok {
+ if connUUID, ok := connectionSettings["uuid"].(string); ok && connUUID == uuid {
+ targetConnection = conn
+ break
+ }
+ }
+ }
+
+ if targetConnection == nil {
+ return fmt.Errorf("connection with UUID %s not found", uuid)
+ }
+
+ _, err = nm.ActivateConnection(targetConnection, dev, nil)
+ if err != nil {
+ return fmt.Errorf("error activation connection: %w", err)
+ }
+
+ b.updateEthernetState()
+ b.listEthernetConnections()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, error) {
+ if b.ethernetDevice == nil {
+ return nil, fmt.Errorf("no ethernet device available")
+ }
+
+ s := b.settings
+ if s == nil {
+ s, err := gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ wiredConfigs := make([]WiredConnection, 0)
+ activeUUIDs, err := b.getActiveConnections()
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to get active wired connections: %w", err)
+ }
+
+ currentUuid := ""
+ for _, connection := range connections {
+ path := connection.GetPath()
+ settings, err := connection.GetSettings()
+ if err != nil {
+ log.Errorf("unable to get settings for %s: %v", path, err)
+ continue
+ }
+
+ connectionSettings := settings["connection"]
+ connType, _ := connectionSettings["type"].(string)
+ connID, _ := connectionSettings["id"].(string)
+ connUUID, _ := connectionSettings["uuid"].(string)
+
+ if connType == "802-3-ethernet" {
+ wiredConfigs = append(wiredConfigs, WiredConnection{
+ Path: path,
+ ID: connID,
+ UUID: connUUID,
+ Type: connType,
+ IsActive: activeUUIDs[connUUID],
+ })
+ if activeUUIDs[connUUID] {
+ currentUuid = connUUID
+ }
+ }
+ }
+
+ b.stateMutex.Lock()
+ b.state.EthernetConnectionUuid = currentUuid
+ b.state.WiredConnections = wiredConfigs
+ b.stateMutex.Unlock()
+
+ return wiredConfigs, nil
+}
diff --git a/backend/internal/server/network/backend_networkmanager_ethernet_test.go b/backend/internal/server/network/backend_networkmanager_ethernet_test.go
new file mode 100644
index 00000000..601306ac
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_ethernet_test.go
@@ -0,0 +1,94 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_GetWiredConnections_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.ethernetDevice = nil
+ _, err = backend.GetWiredConnections()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_GetWiredNetworkDetails_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.ethernetDevice = nil
+ _, err = backend.GetWiredNetworkDetails("test-uuid")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_ConnectEthernet_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.ethernetDevice = nil
+ err = backend.ConnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_DisconnectEthernet_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.ethernetDevice = nil
+ err = backend.DisconnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_ActivateWiredConnection_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.ethernetDevice = nil
+ err = backend.ActivateWiredConnection("test-uuid")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestNetworkManagerBackend_ActivateWiredConnection_NotFound(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ if backend.ethernetDevice == nil {
+ t.Skip("No ethernet device available")
+ }
+
+ err = backend.ActivateWiredConnection("non-existent-uuid-12345")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestNetworkManagerBackend_ListEthernetConnections_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.ethernetDevice = nil
+ _, err = backend.listEthernetConnections()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
diff --git a/backend/internal/server/network/backend_networkmanager_signals.go b/backend/internal/server/network/backend_networkmanager_signals.go
new file mode 100644
index 00000000..153a9d83
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_signals.go
@@ -0,0 +1,321 @@
+package network
+
+import (
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/godbus/dbus/v5"
+)
+
+func (b *NetworkManagerBackend) startSignalPump() error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return err
+ }
+ b.dbusConn = conn
+
+ signals := make(chan *dbus.Signal, 256)
+ b.signals = signals
+ conn.Signal(signals)
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
+ dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
+ dbus.WithMatchMember("NewConnection"),
+ ); err != nil {
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
+ dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
+ dbus.WithMatchMember("ConnectionRemoved"),
+ ); err != nil {
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
+ dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
+ dbus.WithMatchMember("NewConnection"),
+ )
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+
+ if b.wifiDevice != nil {
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+ }
+
+ if b.ethernetDevice != nil {
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ if b.wifiDevice != nil {
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+ conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ }
+ conn.RemoveSignal(signals)
+ conn.Close()
+ return err
+ }
+ }
+
+ b.sigWG.Add(1)
+ go func() {
+ defer b.sigWG.Done()
+ for {
+ select {
+ case <-b.stopChan:
+ return
+ case sig, ok := <-signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ b.handleDBusSignal(sig)
+ }
+ }
+ }()
+ return nil
+}
+
+func (b *NetworkManagerBackend) stopSignalPump() {
+ if b.dbusConn == nil {
+ return
+ }
+
+ b.dbusConn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+
+ if b.wifiDevice != nil {
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+ b.dbusConn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ }
+
+ if b.ethernetDevice != nil {
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+ b.dbusConn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
+ dbus.WithMatchInterface(dbusPropsInterface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ )
+ }
+
+ if b.signals != nil {
+ b.dbusConn.RemoveSignal(b.signals)
+ close(b.signals)
+ }
+
+ b.sigWG.Wait()
+
+ b.dbusConn.Close()
+}
+
+func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
+ if sig.Name == "org.freedesktop.NetworkManager.Settings.NewConnection" ||
+ sig.Name == "org.freedesktop.NetworkManager.Settings.ConnectionRemoved" {
+ b.ListVPNProfiles()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return
+ }
+
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok {
+ return
+ }
+
+ changes, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ switch iface {
+ case dbusNMInterface:
+ b.handleNetworkManagerChange(changes)
+
+ case dbusNMDeviceInterface:
+ b.handleDeviceChange(changes)
+
+ case dbusNMWirelessInterface:
+ b.handleWiFiChange(changes)
+
+ case dbusNMAccessPointInterface:
+ b.handleAccessPointChange(changes)
+ }
+}
+
+func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]dbus.Variant) {
+ var needsUpdate bool
+
+ for key := range changes {
+ switch key {
+ case "PrimaryConnection", "State", "ActiveConnections":
+ needsUpdate = true
+ case "WirelessEnabled":
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ if enabled, err := nm.GetPropertyWirelessEnabled(); err == nil {
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = enabled
+ b.stateMutex.Unlock()
+ needsUpdate = true
+ }
+ default:
+ continue
+ }
+ }
+
+ if needsUpdate {
+ b.updatePrimaryConnection()
+ if _, exists := changes["State"]; exists {
+ b.updateEthernetState()
+ b.updateWiFiState()
+ }
+ if _, exists := changes["ActiveConnections"]; exists {
+ b.updateVPNConnectionState()
+ b.ListActiveVPN()
+ }
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+}
+
+func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Variant) {
+ var needsUpdate bool
+ var stateChanged bool
+
+ for key := range changes {
+ switch key {
+ case "State":
+ stateChanged = true
+ needsUpdate = true
+ case "Ip4Config":
+ needsUpdate = true
+ default:
+ continue
+ }
+ }
+
+ if needsUpdate {
+ b.updateEthernetState()
+ b.updateWiFiState()
+ if stateChanged {
+ b.updatePrimaryConnection()
+ }
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+}
+
+func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) {
+ var needsStateUpdate bool
+ var needsNetworkUpdate bool
+
+ for key := range changes {
+ switch key {
+ case "ActiveAccessPoint":
+ needsStateUpdate = true
+ needsNetworkUpdate = true
+ case "AccessPoints":
+ needsNetworkUpdate = true
+ default:
+ continue
+ }
+ }
+
+ if needsStateUpdate {
+ b.updateWiFiState()
+ }
+ if needsNetworkUpdate {
+ b.updateWiFiNetworks()
+ }
+ if needsStateUpdate || needsNetworkUpdate {
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+}
+
+func (b *NetworkManagerBackend) handleAccessPointChange(changes map[string]dbus.Variant) {
+ _, hasStrength := changes["Strength"]
+ if !hasStrength {
+ return
+ }
+
+ b.stateMutex.RLock()
+ oldSignal := b.state.WiFiSignal
+ b.stateMutex.RUnlock()
+
+ b.updateWiFiState()
+
+ b.stateMutex.RLock()
+ newSignal := b.state.WiFiSignal
+ b.stateMutex.RUnlock()
+
+ if signalChangeSignificant(oldSignal, newSignal) {
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+}
diff --git a/backend/internal/server/network/backend_networkmanager_signals_test.go b/backend/internal/server/network/backend_networkmanager_signals_test.go
new file mode 100644
index 00000000..aa90a802
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_signals_test.go
@@ -0,0 +1,240 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_HandleDBusSignal_NewConnection(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.NetworkManager.Settings.NewConnection",
+ Body: []interface{}{"/org/freedesktop/NetworkManager/Settings/1"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemoved(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved",
+ Body: []interface{}{"/org/freedesktop/NetworkManager/Settings/1"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_InvalidBody(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{"only-one-element"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_InvalidInterface(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{123, map[string]dbus.Variant{}},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDBusSignal_InvalidChanges(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ sig := &dbus.Signal{
+ Name: "org.freedesktop.DBus.Properties.PropertiesChanged",
+ Body: []interface{}{dbusNMInterface, "not-a-map"},
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDBusSignal(sig)
+ })
+}
+
+func TestNetworkManagerBackend_HandleNetworkManagerChange(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "PrimaryConnection": dbus.MakeVariant("/"),
+ "State": dbus.MakeVariant(uint32(70)),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleNetworkManagerChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleNetworkManagerChange_WirelessEnabled(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "WirelessEnabled": dbus.MakeVariant(true),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleNetworkManagerChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleNetworkManagerChange_ActiveConnections(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "ActiveConnections": dbus.MakeVariant([]interface{}{}),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleNetworkManagerChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "State": dbus.MakeVariant(uint32(100)),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDeviceChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "Ip4Config": dbus.MakeVariant("/"),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleDeviceChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleWiFiChange_ActiveAccessPoint(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "ActiveAccessPoint": dbus.MakeVariant("/"),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleWiFiChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleWiFiChange_AccessPoints(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "AccessPoints": dbus.MakeVariant([]interface{}{}),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleWiFiChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleAccessPointChange_NoStrength(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ changes := map[string]dbus.Variant{
+ "SomeOtherProperty": dbus.MakeVariant("value"),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleAccessPointChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_HandleAccessPointChange_WithStrength(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.stateMutex.Lock()
+ backend.state.WiFiSignal = 50
+ backend.stateMutex.Unlock()
+
+ changes := map[string]dbus.Variant{
+ "Strength": dbus.MakeVariant(uint8(80)),
+ }
+
+ assert.NotPanics(t, func() {
+ backend.handleAccessPointChange(changes)
+ })
+}
+
+func TestNetworkManagerBackend_StopSignalPump_NoConnection(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.dbusConn = nil
+ assert.NotPanics(t, func() {
+ backend.stopSignalPump()
+ })
+}
diff --git a/backend/internal/server/network/backend_networkmanager_state.go b/backend/internal/server/network/backend_networkmanager_state.go
new file mode 100644
index 00000000..ae22a1cf
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_state.go
@@ -0,0 +1,271 @@
+package network
+
+import (
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (b *NetworkManagerBackend) updatePrimaryConnection() error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return err
+ }
+
+ hasActiveVPN := false
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+ if connType == "vpn" || connType == "wireguard" {
+ state, _ := activeConn.GetPropertyState()
+ if state == 2 {
+ hasActiveVPN = true
+ break
+ }
+ }
+ }
+
+ if hasActiveVPN {
+ b.stateMutex.Lock()
+ b.state.NetworkStatus = StatusVPN
+ b.stateMutex.Unlock()
+ return nil
+ }
+
+ primaryConn, err := nm.GetPropertyPrimaryConnection()
+ if err != nil {
+ return err
+ }
+
+ if primaryConn == nil || primaryConn.GetPath() == "/" {
+ b.stateMutex.Lock()
+ b.state.NetworkStatus = StatusDisconnected
+ b.stateMutex.Unlock()
+ return nil
+ }
+
+ connType, err := primaryConn.GetPropertyType()
+ if err != nil {
+ return err
+ }
+
+ b.stateMutex.Lock()
+ switch connType {
+ case "802-3-ethernet":
+ b.state.NetworkStatus = StatusEthernet
+ case "802-11-wireless":
+ b.state.NetworkStatus = StatusWiFi
+ case "vpn", "wireguard":
+ b.state.NetworkStatus = StatusVPN
+ default:
+ b.state.NetworkStatus = StatusDisconnected
+ }
+ b.stateMutex.Unlock()
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) updateEthernetState() error {
+ if b.ethernetDevice == nil {
+ return nil
+ }
+
+ dev := b.ethernetDevice.(gonetworkmanager.Device)
+
+ iface, err := dev.GetPropertyInterface()
+ if err != nil {
+ return err
+ }
+
+ state, err := dev.GetPropertyState()
+ if err != nil {
+ return err
+ }
+
+ connected := state == gonetworkmanager.NmDeviceStateActivated
+
+ var ip string
+ if connected {
+ ip = b.getDeviceIP(dev)
+ }
+
+ b.stateMutex.Lock()
+ b.state.EthernetDevice = iface
+ b.state.EthernetConnected = connected
+ b.state.EthernetIP = ip
+ b.stateMutex.Unlock()
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) getDeviceStateReason(dev gonetworkmanager.Device) uint32 {
+ path := dev.GetPath()
+ obj := b.dbusConn.Object("org.freedesktop.NetworkManager", path)
+
+ variant, err := obj.GetProperty(dbusNMDeviceInterface + ".StateReason")
+ if err != nil {
+ return 0
+ }
+
+ if stateReasonStruct, ok := variant.Value().([]interface{}); ok && len(stateReasonStruct) >= 2 {
+ if reason, ok := stateReasonStruct[1].(uint32); ok {
+ return reason
+ }
+ }
+
+ return 0
+}
+
+func (b *NetworkManagerBackend) classifyNMStateReason(reason uint32) string {
+ switch reason {
+ case NmDeviceStateReasonWrongPassword,
+ NmDeviceStateReasonSupplicantTimeout,
+ NmDeviceStateReasonSupplicantFailed,
+ NmDeviceStateReasonSecretsRequired:
+ return errdefs.ErrBadCredentials
+ case NmDeviceStateReasonNoSecrets:
+ return errdefs.ErrUserCanceled
+ case NmDeviceStateReasonNoSsid:
+ return errdefs.ErrNoSuchSSID
+ case NmDeviceStateReasonDhcpClientFailed,
+ NmDeviceStateReasonIpConfigUnavailable:
+ return errdefs.ErrDhcpTimeout
+ case NmDeviceStateReasonSupplicantDisconnect,
+ NmDeviceStateReasonCarrier:
+ return errdefs.ErrAssocTimeout
+ default:
+ return errdefs.ErrConnectionFailed
+ }
+}
+
+func (b *NetworkManagerBackend) updateWiFiState() error {
+ if b.wifiDevice == nil {
+ return nil
+ }
+
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+
+ iface, err := dev.GetPropertyInterface()
+ if err != nil {
+ return err
+ }
+
+ state, err := dev.GetPropertyState()
+ if err != nil {
+ return err
+ }
+
+ connected := state == gonetworkmanager.NmDeviceStateActivated
+ failed := state == gonetworkmanager.NmDeviceStateFailed
+ disconnected := state == gonetworkmanager.NmDeviceStateDisconnected
+
+ var ip, ssid, bssid string
+ var signal uint8
+
+ if connected {
+ if err := b.ensureWiFiDevice(); err == nil && b.wifiDev != nil {
+ w := b.wifiDev.(gonetworkmanager.DeviceWireless)
+ activeAP, err := w.GetPropertyActiveAccessPoint()
+ if err == nil && activeAP != nil && activeAP.GetPath() != "/" {
+ ssid, _ = activeAP.GetPropertySSID()
+ signal, _ = activeAP.GetPropertyStrength()
+ bssid, _ = activeAP.GetPropertyHWAddress()
+ }
+ }
+
+ ip = b.getDeviceIP(dev)
+ }
+
+ b.stateMutex.RLock()
+ wasConnecting := b.state.IsConnecting
+ connectingSSID := b.state.ConnectingSSID
+ b.stateMutex.RUnlock()
+
+ var reasonCode string
+ if wasConnecting && connectingSSID != "" && (failed || (disconnected && !connected)) {
+ reason := b.getDeviceStateReason(dev)
+
+ if reason == NmDeviceStateReasonNewActivation || reason == 0 {
+ return nil
+ }
+
+ log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d, reason=%d", connectingSSID, state, reason)
+
+ reasonCode = b.classifyNMStateReason(reason)
+
+ if reasonCode == errdefs.ErrConnectionFailed {
+ b.failedMutex.RLock()
+ if b.lastFailedSSID == connectingSSID {
+ elapsed := time.Now().Unix() - b.lastFailedTime
+ if elapsed < 5 {
+ reasonCode = errdefs.ErrBadCredentials
+ }
+ }
+ b.failedMutex.RUnlock()
+ }
+ }
+
+ b.stateMutex.Lock()
+ defer b.stateMutex.Unlock()
+
+ wasConnecting = b.state.IsConnecting
+ connectingSSID = b.state.ConnectingSSID
+
+ if wasConnecting && connectingSSID != "" {
+ if connected && ssid == connectingSSID {
+ log.Infof("[updateWiFiState] Connection successful: %s", ssid)
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = ""
+ } else if failed || (disconnected && !connected) {
+ log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state)
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = reasonCode
+
+ // If user cancelled, delete the connection profile that was just created
+ if reasonCode == errdefs.ErrUserCanceled {
+ log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", connectingSSID)
+ b.stateMutex.Unlock()
+ if err := b.ForgetWiFiNetwork(connectingSSID); err != nil {
+ log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err)
+ }
+ b.stateMutex.Lock()
+ }
+
+ b.failedMutex.Lock()
+ b.lastFailedSSID = connectingSSID
+ b.lastFailedTime = time.Now().Unix()
+ b.failedMutex.Unlock()
+ }
+ }
+
+ b.state.WiFiDevice = iface
+ b.state.WiFiConnected = connected
+ b.state.WiFiIP = ip
+ b.state.WiFiSSID = ssid
+ b.state.WiFiBSSID = bssid
+ b.state.WiFiSignal = signal
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) getDeviceIP(dev gonetworkmanager.Device) string {
+ ip4Config, err := dev.GetPropertyIP4Config()
+ if err != nil || ip4Config == nil {
+ return ""
+ }
+
+ addresses, err := ip4Config.GetPropertyAddressData()
+ if err != nil || len(addresses) == 0 {
+ return ""
+ }
+
+ return addresses[0].Address
+}
diff --git a/backend/internal/server/network/backend_networkmanager_state_test.go b/backend/internal/server/network/backend_networkmanager_state_test.go
new file mode 100644
index 00000000..e4996660
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_state_test.go
@@ -0,0 +1,82 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_UpdatePrimaryConnection(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+ mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil)
+
+ err = backend.updatePrimaryConnection()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_UpdateEthernetState_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.ethernetDevice = nil
+ err = backend.updateEthernetState()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_UpdateWiFiState_NoDevice(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.wifiDevice = nil
+ err = backend.updateWiFiState()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_ClassifyNMStateReason(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ testCases := []struct {
+ reason uint32
+ expected string
+ }{
+ {NmDeviceStateReasonWrongPassword, errdefs.ErrBadCredentials},
+ {NmDeviceStateReasonNoSecrets, errdefs.ErrUserCanceled},
+ {NmDeviceStateReasonSupplicantTimeout, errdefs.ErrBadCredentials},
+ {NmDeviceStateReasonDhcpClientFailed, errdefs.ErrDhcpTimeout},
+ {NmDeviceStateReasonNoSsid, errdefs.ErrNoSuchSSID},
+ {999, errdefs.ErrConnectionFailed},
+ }
+
+ for _, tc := range testCases {
+ result := backend.classifyNMStateReason(tc.reason)
+ assert.Equal(t, tc.expected, result)
+ }
+}
+
+func TestNetworkManagerBackend_GetDeviceIP_NoConfig(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockDevice := mock_gonetworkmanager.NewMockDevice(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockDevice.EXPECT().GetPropertyIP4Config().Return(nil, nil)
+
+ ip := backend.getDeviceIP(mockDevice)
+ assert.Empty(t, ip)
+}
diff --git a/backend/internal/server/network/backend_networkmanager_test.go b/backend/internal/server/network/backend_networkmanager_test.go
new file mode 100644
index 00000000..4d9ba23b
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_test.go
@@ -0,0 +1,154 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_New(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ assert.NotNil(t, backend)
+ assert.Equal(t, "networkmanager", backend.state.Backend)
+ assert.NotNil(t, backend.stopChan)
+ assert.NotNil(t, backend.state)
+}
+
+func TestNetworkManagerBackend_GetCurrentState(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.state.NetworkStatus = StatusWiFi
+ backend.state.WiFiConnected = true
+ backend.state.WiFiSSID = "TestNetwork"
+ backend.state.WiFiIP = "192.168.1.100"
+ backend.state.WiFiNetworks = []WiFiNetwork{
+ {SSID: "TestNetwork", Signal: 80, Connected: true},
+ }
+ backend.state.WiredConnections = []WiredConnection{
+ {ID: "Wired connection 1", UUID: "test-uuid"},
+ }
+
+ state, err := backend.GetCurrentState()
+ assert.NoError(t, err)
+ assert.NotNil(t, state)
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+ assert.True(t, state.WiFiConnected)
+ assert.Equal(t, "TestNetwork", state.WiFiSSID)
+ assert.Len(t, state.WiFiNetworks, 1)
+ assert.Len(t, state.WiredConnections, 1)
+
+ assert.NotSame(t, &backend.state.WiFiNetworks, &state.WiFiNetworks)
+ assert.NotSame(t, &backend.state.WiredConnections, &state.WiredConnections)
+}
+
+func TestNetworkManagerBackend_SetPromptBroker_Nil(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ err = backend.SetPromptBroker(nil)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "cannot be nil")
+}
+
+func TestNetworkManagerBackend_SubmitCredentials_NoBroker(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.promptBroker = nil
+ err = backend.SubmitCredentials("token", map[string]string{"password": "test"}, false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not initialized")
+}
+
+func TestNetworkManagerBackend_CancelCredentials_NoBroker(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.promptBroker = nil
+ err = backend.CancelCredentials("token")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not initialized")
+}
+
+func TestNetworkManagerBackend_EnsureWiFiDevice_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDevice = nil
+ backend.wifiDev = nil
+
+ err = backend.ensureWiFiDevice()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_EnsureWiFiDevice_AlreadySet(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDev = "dummy-device"
+
+ err = backend.ensureWiFiDevice()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_StartSecretAgent_NoBroker(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.promptBroker = nil
+ err = backend.startSecretAgent()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "prompt broker not set")
+}
+
+func TestNetworkManagerBackend_Close(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ assert.NotPanics(t, func() {
+ backend.Close()
+ })
+}
+
+func TestNetworkManagerBackend_GetPromptBroker(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ broker := backend.GetPromptBroker()
+ assert.Nil(t, broker)
+}
+
+func TestNetworkManagerBackend_StopMonitoring_NoSignals(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ assert.NotPanics(t, func() {
+ backend.StopMonitoring()
+ })
+}
diff --git a/backend/internal/server/network/backend_networkmanager_vpn.go b/backend/internal/server/network/backend_networkmanager_vpn.go
new file mode 100644
index 00000000..58fab05a
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_vpn.go
@@ -0,0 +1,527 @@
+package network
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (b *NetworkManagerBackend) ListVPNProfiles() ([]VPNProfile, error) {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ var profiles []VPNProfile
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ profile := VPNProfile{
+ Name: connID,
+ UUID: connUUID,
+ Type: connType,
+ }
+
+ if connType == "vpn" {
+ if vpnSettings, ok := settings["vpn"]; ok {
+ if svcType, ok := vpnSettings["service-type"].(string); ok {
+ profile.ServiceType = svcType
+ }
+ }
+ }
+
+ profiles = append(profiles, profile)
+ }
+
+ sort.Slice(profiles, func(i, j int) bool {
+ return strings.ToLower(profiles[i].Name) < strings.ToLower(profiles[j].Name)
+ })
+
+ b.stateMutex.Lock()
+ b.state.VPNProfiles = profiles
+ b.stateMutex.Unlock()
+
+ return profiles, nil
+}
+
+func (b *NetworkManagerBackend) ListActiveVPN() ([]VPNActive, error) {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ var active []VPNActive
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ uuid, _ := activeConn.GetPropertyUUID()
+ id, _ := activeConn.GetPropertyID()
+ state, _ := activeConn.GetPropertyState()
+
+ var stateStr string
+ switch state {
+ case 0:
+ stateStr = "unknown"
+ case 1:
+ stateStr = "activating"
+ case 2:
+ stateStr = "activated"
+ case 3:
+ stateStr = "deactivating"
+ case 4:
+ stateStr = "deactivated"
+ }
+
+ vpnActive := VPNActive{
+ Name: id,
+ UUID: uuid,
+ State: stateStr,
+ Type: connType,
+ Plugin: "",
+ }
+
+ if connType == "vpn" {
+ conn, _ := activeConn.GetPropertyConnection()
+ if conn != nil {
+ connSettings, err := conn.GetSettings()
+ if err == nil {
+ if vpnSettings, ok := connSettings["vpn"]; ok {
+ if svcType, ok := vpnSettings["service-type"].(string); ok {
+ vpnActive.Plugin = svcType
+ }
+ }
+ }
+ }
+ }
+
+ active = append(active, vpnActive)
+ }
+
+ b.stateMutex.Lock()
+ b.state.VPNActive = active
+ b.stateMutex.Unlock()
+
+ return active, nil
+}
+
+func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool) error {
+ if singleActive {
+ active, err := b.ListActiveVPN()
+ if err == nil && len(active) > 0 {
+ alreadyConnected := false
+ for _, vpn := range active {
+ if vpn.UUID == uuidOrName || vpn.Name == uuidOrName {
+ alreadyConnected = true
+ break
+ }
+ }
+
+ if !alreadyConnected {
+ if err := b.DisconnectAllVPN(); err != nil {
+ log.Warnf("Failed to disconnect existing VPNs: %v", err)
+ }
+ time.Sleep(500 * time.Millisecond)
+ } else {
+ return nil
+ }
+ }
+ }
+
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ var targetConn gonetworkmanager.Connection
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID == uuidOrName || connID == uuidOrName {
+ targetConn = conn
+ break
+ }
+ }
+
+ if targetConn == nil {
+ return fmt.Errorf("VPN connection not found: %s", uuidOrName)
+ }
+
+ targetSettings, err := targetConn.GetSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get connection settings: %w", err)
+ }
+
+ var targetUUID string
+ if connMeta, ok := targetSettings["connection"]; ok {
+ if uuid, ok := connMeta["uuid"].(string); ok {
+ targetUUID = uuid
+ }
+ }
+
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = true
+ b.state.ConnectingVPNUUID = targetUUID
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ activeConn, err := nm.ActivateConnection(targetConn, nil, nil)
+ if err != nil {
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return fmt.Errorf("failed to activate VPN: %w", err)
+ }
+
+ if activeConn != nil {
+ state, _ := activeConn.GetPropertyState()
+ if state == 2 {
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.stateMutex.Unlock()
+ b.ListActiveVPN()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) DisconnectVPN(uuidOrName string) error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ log.Debugf("[DisconnectVPN] Looking for VPN: %s", uuidOrName)
+
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ uuid, _ := activeConn.GetPropertyUUID()
+ id, _ := activeConn.GetPropertyID()
+ state, _ := activeConn.GetPropertyState()
+
+ log.Debugf("[DisconnectVPN] Found active VPN: uuid=%s id=%s state=%d", uuid, id, state)
+
+ if uuid == uuidOrName || id == uuidOrName {
+ log.Infof("[DisconnectVPN] Deactivating VPN: %s (state=%d)", id, state)
+ if err := nm.DeactivateConnection(activeConn); err != nil {
+ return fmt.Errorf("failed to deactivate VPN: %w", err)
+ }
+ b.ListActiveVPN()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return nil
+ }
+ }
+
+ log.Warnf("[DisconnectVPN] VPN not found in active connections: %s", uuidOrName)
+
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("VPN connection not active and cannot access settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("VPN connection not active: %s", uuidOrName)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID == uuidOrName || connID == uuidOrName {
+ log.Infof("[DisconnectVPN] VPN connection exists but not active: %s", connID)
+ return nil
+ }
+ }
+
+ return fmt.Errorf("VPN connection not found: %s", uuidOrName)
+}
+
+func (b *NetworkManagerBackend) DisconnectAllVPN() error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get active connections: %w", err)
+ }
+
+ var lastErr error
+ var disconnected bool
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ if err := nm.DeactivateConnection(activeConn); err != nil {
+ lastErr = err
+ log.Warnf("Failed to deactivate VPN connection: %v", err)
+ } else {
+ disconnected = true
+ }
+ }
+
+ if disconnected {
+ b.ListActiveVPN()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ }
+
+ return lastErr
+}
+
+func (b *NetworkManagerBackend) ClearVPNCredentials(uuidOrName string) error {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ settings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ connMeta, ok := settings["connection"]
+ if !ok {
+ continue
+ }
+
+ connType, _ := connMeta["type"].(string)
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ connID, _ := connMeta["id"].(string)
+ connUUID, _ := connMeta["uuid"].(string)
+
+ if connUUID == uuidOrName || connID == uuidOrName {
+ if connType == "vpn" {
+ if vpnSettings, ok := settings["vpn"]; ok {
+ delete(vpnSettings, "secrets")
+
+ if dataMap, ok := vpnSettings["data"].(map[string]string); ok {
+ dataMap["password-flags"] = "1"
+ vpnSettings["data"] = dataMap
+ }
+
+ vpnSettings["password-flags"] = uint32(1)
+ }
+
+ settings["vpn-secrets"] = make(map[string]interface{})
+ }
+
+ if err := conn.Update(settings); err != nil {
+ return fmt.Errorf("failed to update connection: %w", err)
+ }
+
+ if err := conn.ClearSecrets(); err != nil {
+ log.Warnf("ClearSecrets call failed (may not be critical): %v", err)
+ }
+
+ log.Infof("Cleared credentials for VPN: %s", connID)
+ return nil
+ }
+ }
+
+ return fmt.Errorf("VPN connection not found: %s", uuidOrName)
+}
+
+func (b *NetworkManagerBackend) updateVPNConnectionState() {
+ b.stateMutex.RLock()
+ isConnectingVPN := b.state.IsConnectingVPN
+ connectingVPNUUID := b.state.ConnectingVPNUUID
+ b.stateMutex.RUnlock()
+
+ if !isConnectingVPN || connectingVPNUUID == "" {
+ return
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ activeConns, err := nm.GetPropertyActiveConnections()
+ if err != nil {
+ return
+ }
+
+ foundConnection := false
+ for _, activeConn := range activeConns {
+ connType, err := activeConn.GetPropertyType()
+ if err != nil {
+ continue
+ }
+
+ if connType != "vpn" && connType != "wireguard" {
+ continue
+ }
+
+ uuid, err := activeConn.GetPropertyUUID()
+ if err != nil {
+ continue
+ }
+
+ state, _ := activeConn.GetPropertyState()
+ stateReason, _ := activeConn.GetPropertyStateFlags()
+
+ if uuid == connectingVPNUUID {
+ foundConnection = true
+
+ switch state {
+ case 2:
+ log.Infof("[updateVPNConnectionState] VPN connection successful: %s", uuid)
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+ return
+ case 4:
+ log.Warnf("[updateVPNConnectionState] VPN connection failed/deactivated: %s (state=%d, flags=%d)", uuid, state, stateReason)
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.state.LastError = "VPN connection failed"
+ b.stateMutex.Unlock()
+ return
+ }
+ }
+ }
+
+ if !foundConnection {
+ log.Warnf("[updateVPNConnectionState] VPN connection no longer exists: %s", connectingVPNUUID)
+ b.stateMutex.Lock()
+ b.state.IsConnectingVPN = false
+ b.state.ConnectingVPNUUID = ""
+ b.state.LastError = "VPN connection failed"
+ b.stateMutex.Unlock()
+ }
+}
diff --git a/backend/internal/server/network/backend_networkmanager_vpn_test.go b/backend/internal/server/network/backend_networkmanager_vpn_test.go
new file mode 100644
index 00000000..89a7f200
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_vpn_test.go
@@ -0,0 +1,138 @@
+package network
+
+import (
+ "testing"
+
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_ListVPNProfiles(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+
+ profiles, err := backend.ListVPNProfiles()
+ assert.NoError(t, err)
+ assert.Empty(t, profiles)
+}
+
+func TestNetworkManagerBackend_ListActiveVPN(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ active, err := backend.ListActiveVPN()
+ assert.NoError(t, err)
+ assert.Empty(t, active)
+}
+
+func TestNetworkManagerBackend_ConnectVPN_NotFound(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+
+ err = backend.ConnectVPN("non-existent-vpn-12345", false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestNetworkManagerBackend_ConnectVPN_SingleActive_NoActiveVPN(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ err = backend.ConnectVPN("non-existent-vpn-12345", true)
+ assert.Error(t, err)
+}
+
+func TestNetworkManagerBackend_DisconnectVPN_NotActive(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ err = backend.DisconnectVPN("non-existent-vpn-12345")
+ assert.Error(t, err)
+}
+
+func TestNetworkManagerBackend_DisconnectAllVPN(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil)
+
+ err = backend.DisconnectAllVPN()
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_ClearVPNCredentials_NotFound(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+ mockSettings := mock_gonetworkmanager.NewMockSettings(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+ backend.settings = mockSettings
+
+ mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil)
+
+ err = backend.ClearVPNCredentials("non-existent-vpn-12345")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestNetworkManagerBackend_UpdateVPNConnectionState_NotConnecting(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnectingVPN = false
+ backend.state.ConnectingVPNUUID = ""
+ backend.stateMutex.Unlock()
+
+ assert.NotPanics(t, func() {
+ backend.updateVPNConnectionState()
+ })
+}
+
+func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnectingVPN = true
+ backend.state.ConnectingVPNUUID = ""
+ backend.stateMutex.Unlock()
+
+ assert.NotPanics(t, func() {
+ backend.updateVPNConnectionState()
+ })
+}
diff --git a/backend/internal/server/network/backend_networkmanager_wifi.go b/backend/internal/server/network/backend_networkmanager_wifi.go
new file mode 100644
index 00000000..d1d54243
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_wifi.go
@@ -0,0 +1,718 @@
+package network
+
+import (
+ "bytes"
+ "fmt"
+ "sort"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ return nm.GetPropertyWirelessEnabled()
+}
+
+func (b *NetworkManagerBackend) SetWiFiEnabled(enabled bool) error {
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ err := nm.SetPropertyWirelessEnabled(enabled)
+ if err != nil {
+ return fmt.Errorf("failed to set WiFi enabled: %w", err)
+ }
+
+ b.stateMutex.Lock()
+ b.state.WiFiEnabled = enabled
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) ScanWiFi() error {
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ b.stateMutex.RLock()
+ enabled := b.state.WiFiEnabled
+ b.stateMutex.RUnlock()
+
+ if !enabled {
+ return fmt.Errorf("WiFi is disabled")
+ }
+
+ if err := b.ensureWiFiDevice(); err != nil {
+ return err
+ }
+
+ w := b.wifiDev.(gonetworkmanager.DeviceWireless)
+ err := w.RequestScan()
+ if err != nil {
+ return fmt.Errorf("scan request failed: %w", err)
+ }
+
+ _, err = b.updateWiFiNetworks()
+ return err
+}
+
+func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
+ if b.wifiDevice == nil {
+ return nil, fmt.Errorf("no WiFi device available")
+ }
+
+ if err := b.ensureWiFiDevice(); err != nil {
+ return nil, err
+ }
+ wifiDev := b.wifiDev
+
+ w := wifiDev.(gonetworkmanager.DeviceWireless)
+ apPaths, err := w.GetAccessPoints()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get access points: %w", err)
+ }
+
+ s := b.settings
+ if s == nil {
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ savedSSIDs := make(map[string]bool)
+ autoconnectMap := make(map[string]bool)
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" {
+ if wifiSettings, ok := connSettings["802-11-wireless"]; ok {
+ if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok {
+ savedSSID := string(ssidBytes)
+ savedSSIDs[savedSSID] = true
+ autoconnect := true
+ if ac, ok := connMeta["autoconnect"].(bool); ok {
+ autoconnect = ac
+ }
+ autoconnectMap[savedSSID] = autoconnect
+ }
+ }
+ }
+ }
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ currentBSSID := b.state.WiFiBSSID
+ b.stateMutex.RUnlock()
+
+ var bands []WiFiNetwork
+
+ for _, ap := range apPaths {
+ apSSID, err := ap.GetPropertySSID()
+ if err != nil || apSSID != ssid {
+ continue
+ }
+
+ strength, _ := ap.GetPropertyStrength()
+ flags, _ := ap.GetPropertyFlags()
+ wpaFlags, _ := ap.GetPropertyWPAFlags()
+ rsnFlags, _ := ap.GetPropertyRSNFlags()
+ freq, _ := ap.GetPropertyFrequency()
+ maxBitrate, _ := ap.GetPropertyMaxBitrate()
+ bssid, _ := ap.GetPropertyHWAddress()
+ mode, _ := ap.GetPropertyMode()
+
+ secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
+ wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
+ rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
+
+ enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) ||
+ (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0)
+
+ var modeStr string
+ switch mode {
+ case gonetworkmanager.Nm80211ModeAdhoc:
+ modeStr = "adhoc"
+ case gonetworkmanager.Nm80211ModeInfra:
+ modeStr = "infrastructure"
+ case gonetworkmanager.Nm80211ModeAp:
+ modeStr = "ap"
+ default:
+ modeStr = "unknown"
+ }
+
+ channel := frequencyToChannel(freq)
+
+ network := WiFiNetwork{
+ SSID: ssid,
+ BSSID: bssid,
+ Signal: strength,
+ Secured: secured,
+ Enterprise: enterprise,
+ Connected: ssid == currentSSID && bssid == currentBSSID,
+ Saved: savedSSIDs[ssid],
+ Autoconnect: autoconnectMap[ssid],
+ Frequency: freq,
+ Mode: modeStr,
+ Rate: maxBitrate / 1000,
+ Channel: channel,
+ }
+
+ bands = append(bands, network)
+ }
+
+ if len(bands) == 0 {
+ return nil, fmt.Errorf("network not found: %s", ssid)
+ }
+
+ sort.Slice(bands, func(i, j int) bool {
+ if bands[i].Connected && !bands[j].Connected {
+ return true
+ }
+ if !bands[i].Connected && bands[j].Connected {
+ return false
+ }
+ return bands[i].Signal > bands[j].Signal
+ })
+
+ return &NetworkInfoResponse{
+ SSID: ssid,
+ Bands: bands,
+ }, nil
+}
+
+func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ b.stateMutex.RLock()
+ alreadyConnected := b.state.WiFiConnected && b.state.WiFiSSID == req.SSID
+ b.stateMutex.RUnlock()
+
+ if alreadyConnected && !req.Interactive {
+ return nil
+ }
+
+ b.stateMutex.Lock()
+ b.state.IsConnecting = true
+ b.state.ConnectingSSID = req.SSID
+ b.state.LastError = ""
+ b.stateMutex.Unlock()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+
+ existingConn, err := b.findConnection(req.SSID)
+ if err == nil && existingConn != nil {
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+
+ _, err := nm.ActivateConnection(existingConn, dev, nil)
+ if err != nil {
+ log.Warnf("[ConnectWiFi] Failed to activate existing connection: %v", err)
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = fmt.Sprintf("failed to activate connection: %v", err)
+ b.stateMutex.Unlock()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return fmt.Errorf("failed to activate connection: %w", err)
+ }
+
+ return nil
+ }
+
+ if err := b.createAndConnectWiFi(req); err != nil {
+ log.Warnf("[ConnectWiFi] Failed to create and connect: %v", err)
+ b.stateMutex.Lock()
+ b.state.IsConnecting = false
+ b.state.ConnectingSSID = ""
+ b.state.LastError = err.Error()
+ b.stateMutex.Unlock()
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+ return err
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) DisconnectWiFi() error {
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+
+ err := dev.Disconnect()
+ if err != nil {
+ return fmt.Errorf("failed to disconnect: %w", err)
+ }
+
+ b.updateWiFiState()
+ b.updatePrimaryConnection()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
+ conn, err := b.findConnection(ssid)
+ if err != nil {
+ return fmt.Errorf("connection not found: %w", err)
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ isConnected := b.state.WiFiConnected
+ b.stateMutex.RUnlock()
+
+ err = conn.Delete()
+ if err != nil {
+ return fmt.Errorf("failed to delete connection: %w", err)
+ }
+
+ if isConnected && currentSSID == ssid {
+ b.stateMutex.Lock()
+ b.state.WiFiConnected = false
+ b.state.WiFiSSID = ""
+ b.state.WiFiBSSID = ""
+ b.state.WiFiSignal = 0
+ b.state.WiFiIP = ""
+ b.state.NetworkStatus = StatusDisconnected
+ b.stateMutex.Unlock()
+ }
+
+ b.updateWiFiNetworks()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool {
+ b.stateMutex.RLock()
+ defer b.stateMutex.RUnlock()
+ return b.state.IsConnecting && b.state.ConnectingSSID == ssid
+}
+
+func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
+ if b.wifiDevice == nil {
+ return nil, fmt.Errorf("no WiFi device available")
+ }
+
+ if err := b.ensureWiFiDevice(); err != nil {
+ return nil, err
+ }
+ wifiDev := b.wifiDev
+
+ w := wifiDev.(gonetworkmanager.DeviceWireless)
+ apPaths, err := w.GetAccessPoints()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get access points: %w", err)
+ }
+
+ s := b.settings
+ if s == nil {
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get settings: %w", err)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ savedSSIDs := make(map[string]bool)
+ autoconnectMap := make(map[string]bool)
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" {
+ if wifiSettings, ok := connSettings["802-11-wireless"]; ok {
+ if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok {
+ ssid := string(ssidBytes)
+ savedSSIDs[ssid] = true
+ autoconnect := true
+ if ac, ok := connMeta["autoconnect"].(bool); ok {
+ autoconnect = ac
+ }
+ autoconnectMap[ssid] = autoconnect
+ }
+ }
+ }
+ }
+ }
+
+ b.stateMutex.RLock()
+ currentSSID := b.state.WiFiSSID
+ b.stateMutex.RUnlock()
+
+ seenSSIDs := make(map[string]*WiFiNetwork)
+ networks := []WiFiNetwork{}
+
+ for _, ap := range apPaths {
+ ssid, err := ap.GetPropertySSID()
+ if err != nil || ssid == "" {
+ continue
+ }
+
+ if existing, exists := seenSSIDs[ssid]; exists {
+ strength, _ := ap.GetPropertyStrength()
+ if strength > existing.Signal {
+ existing.Signal = strength
+ freq, _ := ap.GetPropertyFrequency()
+ existing.Frequency = freq
+ bssid, _ := ap.GetPropertyHWAddress()
+ existing.BSSID = bssid
+ }
+ continue
+ }
+
+ strength, _ := ap.GetPropertyStrength()
+ flags, _ := ap.GetPropertyFlags()
+ wpaFlags, _ := ap.GetPropertyWPAFlags()
+ rsnFlags, _ := ap.GetPropertyRSNFlags()
+ freq, _ := ap.GetPropertyFrequency()
+ maxBitrate, _ := ap.GetPropertyMaxBitrate()
+ bssid, _ := ap.GetPropertyHWAddress()
+ mode, _ := ap.GetPropertyMode()
+
+ secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
+ wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
+ rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
+
+ enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) ||
+ (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0)
+
+ var modeStr string
+ switch mode {
+ case gonetworkmanager.Nm80211ModeAdhoc:
+ modeStr = "adhoc"
+ case gonetworkmanager.Nm80211ModeInfra:
+ modeStr = "infrastructure"
+ case gonetworkmanager.Nm80211ModeAp:
+ modeStr = "ap"
+ default:
+ modeStr = "unknown"
+ }
+
+ channel := frequencyToChannel(freq)
+
+ network := WiFiNetwork{
+ SSID: ssid,
+ BSSID: bssid,
+ Signal: strength,
+ Secured: secured,
+ Enterprise: enterprise,
+ Connected: ssid == currentSSID,
+ Saved: savedSSIDs[ssid],
+ Autoconnect: autoconnectMap[ssid],
+ Frequency: freq,
+ Mode: modeStr,
+ Rate: maxBitrate / 1000,
+ Channel: channel,
+ }
+
+ seenSSIDs[ssid] = &network
+ networks = append(networks, network)
+ }
+
+ sortWiFiNetworks(networks)
+
+ b.stateMutex.Lock()
+ b.state.WiFiNetworks = networks
+ b.stateMutex.Unlock()
+
+ return networks, nil
+}
+
+func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) {
+ s := b.settings
+ if s == nil {
+ var err error
+ s, err = gonetworkmanager.NewSettings()
+ if err != nil {
+ return nil, err
+ }
+ b.settings = s
+ }
+
+ settings := s.(gonetworkmanager.Settings)
+ connections, err := settings.ListConnections()
+ if err != nil {
+ return nil, err
+ }
+
+ ssidBytes := []byte(ssid)
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" {
+ if wifiSettings, ok := connSettings["802-11-wireless"]; ok {
+ if candidateSSID, ok := wifiSettings["ssid"].([]byte); ok {
+ if bytes.Equal(candidateSSID, ssidBytes) {
+ return conn, nil
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("connection not found")
+}
+
+func (b *NetworkManagerBackend) createAndConnectWiFi(req ConnectionRequest) error {
+ if b.wifiDevice == nil {
+ return fmt.Errorf("no WiFi device available")
+ }
+
+ nm := b.nmConn.(gonetworkmanager.NetworkManager)
+ dev := b.wifiDevice.(gonetworkmanager.Device)
+
+ if err := b.ensureWiFiDevice(); err != nil {
+ return err
+ }
+ wifiDev := b.wifiDev
+
+ w := wifiDev.(gonetworkmanager.DeviceWireless)
+ apPaths, err := w.GetAccessPoints()
+ if err != nil {
+ return fmt.Errorf("failed to get access points: %w", err)
+ }
+
+ var targetAP gonetworkmanager.AccessPoint
+ for _, ap := range apPaths {
+ ssid, err := ap.GetPropertySSID()
+ if err != nil || ssid != req.SSID {
+ continue
+ }
+ targetAP = ap
+ break
+ }
+
+ if targetAP == nil {
+ return fmt.Errorf("access point not found: %s", req.SSID)
+ }
+
+ flags, _ := targetAP.GetPropertyFlags()
+ wpaFlags, _ := targetAP.GetPropertyWPAFlags()
+ rsnFlags, _ := targetAP.GetPropertyRSNFlags()
+
+ const KeyMgmt8021x = uint32(512)
+ const KeyMgmtPsk = uint32(256)
+ const KeyMgmtSae = uint32(1024)
+
+ isEnterprise := (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0
+ isPsk := (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
+ isSae := (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
+
+ secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
+ wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
+ rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
+
+ if isEnterprise {
+ log.Infof("[createAndConnectWiFi] Enterprise network detected (802.1x) - SSID: %s, interactive: %v",
+ req.SSID, req.Interactive)
+ }
+
+ settings := make(map[string]map[string]interface{})
+
+ settings["connection"] = map[string]interface{}{
+ "id": req.SSID,
+ "type": "802-11-wireless",
+ "autoconnect": true,
+ }
+
+ settings["ipv4"] = map[string]interface{}{"method": "auto"}
+ settings["ipv6"] = map[string]interface{}{"method": "auto"}
+
+ if secured {
+ settings["802-11-wireless"] = map[string]interface{}{
+ "ssid": []byte(req.SSID),
+ "mode": "infrastructure",
+ "security": "802-11-wireless-security",
+ }
+
+ switch {
+ case isEnterprise || req.Username != "":
+ settings["802-11-wireless-security"] = map[string]interface{}{
+ "key-mgmt": "wpa-eap",
+ }
+
+ x := map[string]interface{}{
+ "eap": []string{"peap"},
+ "phase2-auth": "mschapv2",
+ "system-ca-certs": false,
+ "password-flags": uint32(0),
+ }
+
+ if req.Username != "" {
+ x["identity"] = req.Username
+ }
+ if req.Password != "" {
+ x["password"] = req.Password
+ }
+
+ if req.AnonymousIdentity != "" {
+ x["anonymous-identity"] = req.AnonymousIdentity
+ }
+ if req.DomainSuffixMatch != "" {
+ x["domain-suffix-match"] = req.DomainSuffixMatch
+ }
+
+ settings["802-1x"] = x
+
+ log.Infof("[createAndConnectWiFi] WPA-EAP settings: eap=peap, phase2-auth=mschapv2, identity=%s, interactive=%v, system-ca-certs=%v, domain-suffix-match=%q",
+ req.Username, req.Interactive, x["system-ca-certs"], req.DomainSuffixMatch)
+
+ case isPsk:
+ sec := map[string]interface{}{
+ "key-mgmt": "wpa-psk",
+ "psk-flags": uint32(0),
+ }
+ if !req.Interactive {
+ sec["psk"] = req.Password
+ }
+ settings["802-11-wireless-security"] = sec
+
+ case isSae:
+ sec := map[string]interface{}{
+ "key-mgmt": "sae",
+ "pmf": int32(3),
+ "psk-flags": uint32(0),
+ }
+ if !req.Interactive {
+ sec["psk"] = req.Password
+ }
+ settings["802-11-wireless-security"] = sec
+
+ default:
+ return fmt.Errorf("secured network but not SAE/PSK/802.1X (rsn=0x%x wpa=0x%x)", rsnFlags, wpaFlags)
+ }
+ } else {
+ settings["802-11-wireless"] = map[string]interface{}{
+ "ssid": []byte(req.SSID),
+ "mode": "infrastructure",
+ }
+ }
+
+ if req.Interactive {
+ s := b.settings
+ if s == nil {
+ var settingsErr error
+ s, settingsErr = gonetworkmanager.NewSettings()
+ if settingsErr != nil {
+ return fmt.Errorf("failed to get settings manager: %w", settingsErr)
+ }
+ b.settings = s
+ }
+
+ settingsMgr := s.(gonetworkmanager.Settings)
+ conn, err := settingsMgr.AddConnection(settings)
+ if err != nil {
+ return fmt.Errorf("failed to add connection: %w", err)
+ }
+
+ if isEnterprise {
+ log.Infof("[createAndConnectWiFi] Enterprise connection added, activating (secret agent will be called)")
+ }
+
+ _, err = nm.ActivateWirelessConnection(conn, dev, targetAP)
+ if err != nil {
+ return fmt.Errorf("failed to activate connection: %w", err)
+ }
+
+ log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...")
+ } else {
+ _, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP)
+ if err != nil {
+ return fmt.Errorf("failed to connect: %w", err)
+ }
+ log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...")
+ }
+
+ return nil
+}
+
+func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ conn, err := b.findConnection(ssid)
+ if err != nil {
+ return fmt.Errorf("connection not found: %w", err)
+ }
+
+ settings, err := conn.GetSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get connection settings: %w", err)
+ }
+
+ if connMeta, ok := settings["connection"]; ok {
+ connMeta["autoconnect"] = autoconnect
+ } else {
+ return fmt.Errorf("connection metadata not found")
+ }
+
+ if ipv4, ok := settings["ipv4"]; ok {
+ delete(ipv4, "addresses")
+ delete(ipv4, "routes")
+ delete(ipv4, "dns")
+ }
+
+ if ipv6, ok := settings["ipv6"]; ok {
+ delete(ipv6, "addresses")
+ delete(ipv6, "routes")
+ delete(ipv6, "dns")
+ }
+
+ err = conn.Update(settings)
+ if err != nil {
+ return fmt.Errorf("failed to update connection: %w", err)
+ }
+
+ b.updateWiFiNetworks()
+
+ if b.onStateChange != nil {
+ b.onStateChange()
+ }
+
+ return nil
+}
diff --git a/backend/internal/server/network/backend_networkmanager_wifi_test.go b/backend/internal/server/network/backend_networkmanager_wifi_test.go
new file mode 100644
index 00000000..b4e973fe
--- /dev/null
+++ b/backend/internal/server/network/backend_networkmanager_wifi_test.go
@@ -0,0 +1,198 @@
+package network
+
+import (
+ "testing"
+
+ mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNetworkManagerBackend_GetWiFiEnabled(t *testing.T) {
+ mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+
+ backend, err := NewNetworkManagerBackend(mockNM)
+ assert.NoError(t, err)
+
+ mockNM.EXPECT().GetPropertyWirelessEnabled().Return(true, nil)
+
+ enabled, err := backend.GetWiFiEnabled()
+ assert.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+func TestNetworkManagerBackend_SetWiFiEnabled(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ originalState, err := backend.GetWiFiEnabled()
+ if err != nil {
+ t.Skipf("Cannot get WiFi state: %v", err)
+ }
+
+ defer func() {
+ backend.SetWiFiEnabled(originalState)
+ }()
+
+ err = backend.SetWiFiEnabled(!originalState)
+ assert.NoError(t, err)
+
+ backend.stateMutex.RLock()
+ assert.Equal(t, !originalState, backend.state.WiFiEnabled)
+ backend.stateMutex.RUnlock()
+}
+
+func TestNetworkManagerBackend_ScanWiFi_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDevice = nil
+ err = backend.ScanWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_ScanWiFi_Disabled(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ if backend.wifiDevice == nil {
+ t.Skip("No WiFi device available")
+ }
+
+ backend.stateMutex.Lock()
+ backend.state.WiFiEnabled = false
+ backend.stateMutex.Unlock()
+
+ err = backend.ScanWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "WiFi is disabled")
+}
+
+func TestNetworkManagerBackend_GetWiFiNetworkDetails_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDevice = nil
+ _, err = backend.GetWiFiNetworkDetails("TestNetwork")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_ConnectWiFi_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDevice = nil
+ req := ConnectionRequest{SSID: "TestNetwork", Password: "password"}
+ err = backend.ConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_ConnectWiFi_AlreadyConnected(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ if backend.wifiDevice == nil {
+ t.Skip("No WiFi device available")
+ }
+
+ backend.stateMutex.Lock()
+ backend.state.WiFiConnected = true
+ backend.state.WiFiSSID = "TestNetwork"
+ backend.stateMutex.Unlock()
+
+ req := ConnectionRequest{SSID: "TestNetwork", Password: "password"}
+ err = backend.ConnectWiFi(req)
+ assert.NoError(t, err)
+}
+
+func TestNetworkManagerBackend_DisconnectWiFi_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDevice = nil
+ err = backend.DisconnectWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_IsConnectingTo(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnecting = true
+ backend.state.ConnectingSSID = "TestNetwork"
+ backend.stateMutex.Unlock()
+
+ assert.True(t, backend.IsConnectingTo("TestNetwork"))
+ assert.False(t, backend.IsConnectingTo("OtherNetwork"))
+}
+
+func TestNetworkManagerBackend_IsConnectingTo_NotConnecting(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.stateMutex.Lock()
+ backend.state.IsConnecting = false
+ backend.state.ConnectingSSID = ""
+ backend.stateMutex.Unlock()
+
+ assert.False(t, backend.IsConnectingTo("TestNetwork"))
+}
+
+func TestNetworkManagerBackend_UpdateWiFiNetworks_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDevice = nil
+ _, err = backend.updateWiFiNetworks()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.settings = nil
+ _, err = backend.findConnection("NonExistentNetwork")
+ assert.Error(t, err)
+}
+
+func TestNetworkManagerBackend_CreateAndConnectWiFi_NoDevice(t *testing.T) {
+ backend, err := NewNetworkManagerBackend()
+ if err != nil {
+ t.Skipf("NetworkManager not available: %v", err)
+ }
+
+ backend.wifiDevice = nil
+ backend.wifiDev = nil
+ req := ConnectionRequest{SSID: "TestNetwork", Password: "password"}
+ err = backend.createAndConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
diff --git a/backend/internal/server/network/broker.go b/backend/internal/server/network/broker.go
new file mode 100644
index 00000000..5290fb59
--- /dev/null
+++ b/backend/internal/server/network/broker.go
@@ -0,0 +1,22 @@
+package network
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+)
+
+type PromptBroker interface {
+ Ask(ctx context.Context, req PromptRequest) (token string, err error)
+ Wait(ctx context.Context, token string) (PromptReply, error)
+ Resolve(token string, reply PromptReply) error
+ Cancel(path string, setting string) error
+}
+
+func generateToken() (string, error) {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/backend/internal/server/network/connection_test.go b/backend/internal/server/network/connection_test.go
new file mode 100644
index 00000000..cefaacc3
--- /dev/null
+++ b/backend/internal/server/network/connection_test.go
@@ -0,0 +1,109 @@
+package network_test
+
+import (
+ "errors"
+ "testing"
+
+ mocks_network "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/network"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/network"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestConnectionRequest_Validation(t *testing.T) {
+ t.Run("basic WiFi connection", func(t *testing.T) {
+ req := network.ConnectionRequest{
+ SSID: "TestNetwork",
+ Password: "testpass123",
+ }
+
+ assert.NotEmpty(t, req.SSID)
+ assert.NotEmpty(t, req.Password)
+ assert.Empty(t, req.Username)
+ })
+
+ t.Run("enterprise WiFi connection", func(t *testing.T) {
+ req := network.ConnectionRequest{
+ SSID: "EnterpriseNetwork",
+ Password: "testpass123",
+ Username: "testuser",
+ }
+
+ assert.NotEmpty(t, req.SSID)
+ assert.NotEmpty(t, req.Password)
+ assert.NotEmpty(t, req.Username)
+ })
+
+ t.Run("open WiFi connection", func(t *testing.T) {
+ req := network.ConnectionRequest{
+ SSID: "OpenNetwork",
+ }
+
+ assert.NotEmpty(t, req.SSID)
+ assert.Empty(t, req.Password)
+ assert.Empty(t, req.Username)
+ })
+}
+
+func TestManager_ConnectWiFi_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ req := network.ConnectionRequest{
+ SSID: "TestNetwork",
+ Password: "testpass123",
+ }
+ backend.EXPECT().ConnectWiFi(req).Return(errors.New("no WiFi device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.ConnectWiFi(req)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestManager_DisconnectWiFi_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().DisconnectWiFi().Return(errors.New("no WiFi device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.DisconnectWiFi()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no WiFi device available")
+}
+
+func TestManager_ForgetWiFiNetwork_NotFound(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().ForgetWiFiNetwork("NonExistentNetwork").Return(errors.New("connection not found"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.ForgetWiFiNetwork("NonExistentNetwork")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "connection not found")
+}
+
+func TestManager_ConnectEthernet_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().ConnectEthernet().Return(errors.New("no ethernet device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.ConnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+func TestManager_DisconnectEthernet_NoDevice(t *testing.T) {
+ backend := mocks_network.NewMockBackend(t)
+ backend.EXPECT().DisconnectEthernet().Return(errors.New("no ethernet device available"))
+
+ manager := network.NewTestManager(backend, &network.NetworkState{})
+
+ err := manager.DisconnectEthernet()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no ethernet device available")
+}
+
+// Note: More comprehensive tests for connection operations would require
+// mocking the NetworkManager D-Bus interfaces, which is beyond the scope
+// of these unit tests. The tests above cover the basic error cases and
+// validation logic. Integration tests would be needed for full coverage.
diff --git a/backend/internal/server/network/detect.go b/backend/internal/server/network/detect.go
new file mode 100644
index 00000000..6031e42f
--- /dev/null
+++ b/backend/internal/server/network/detect.go
@@ -0,0 +1,89 @@
+package network
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+)
+
+type BackendType int
+
+const (
+ BackendNone BackendType = iota
+ BackendNetworkManager
+ BackendIwd
+ BackendConnMan
+ BackendNetworkd
+)
+
+func nameHasOwner(bus *dbus.Conn, name string) (bool, error) {
+ obj := bus.Object("org.freedesktop.DBus", "/org/freedesktop/DBus")
+ var owned bool
+ if err := obj.Call("org.freedesktop.DBus.NameHasOwner", 0, name).Store(&owned); err != nil {
+ return false, err
+ }
+ return owned, nil
+}
+
+type DetectResult struct {
+ Backend BackendType
+ HasNM bool
+ HasIwd bool
+ HasConnMan bool
+ HasWpaSupp bool
+ HasNetworkd bool
+ ChosenReason string
+}
+
+func DetectNetworkStack() (*DetectResult, error) {
+ bus, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("connect system bus: %w", err)
+ }
+ defer bus.Close()
+
+ hasNM, _ := nameHasOwner(bus, "org.freedesktop.NetworkManager")
+ hasIwd, _ := nameHasOwner(bus, "net.connman.iwd")
+ hasConn, _ := nameHasOwner(bus, "net.connman")
+ hasWpa, _ := nameHasOwner(bus, "fi.w1.wpa_supplicant1")
+ hasNetworkd, _ := nameHasOwner(bus, "org.freedesktop.network1")
+
+ res := &DetectResult{
+ HasNM: hasNM,
+ HasIwd: hasIwd,
+ HasConnMan: hasConn,
+ HasWpaSupp: hasWpa,
+ HasNetworkd: hasNetworkd,
+ }
+
+ switch {
+ case hasNM:
+ res.Backend = BackendNetworkManager
+ if hasIwd {
+ res.ChosenReason = "NetworkManager present; iwd also running (likely NM's Wi-Fi backend). Using NM API."
+ } else {
+ res.ChosenReason = "NetworkManager present. Using NM API."
+ }
+ case hasConn && hasIwd:
+ res.Backend = BackendConnMan
+ res.ChosenReason = "ConnMan + iwd detected. Use ConnMan API (iwd is its Wi-Fi daemon)."
+ case hasIwd && hasNetworkd:
+ res.Backend = BackendNetworkd
+ res.ChosenReason = "iwd + systemd-networkd detected. Using iwd for Wi-Fi association and networkd for IP/DHCP."
+ case hasIwd:
+ res.Backend = BackendIwd
+ res.ChosenReason = "iwd detected without NM/ConnMan. Using iwd API."
+ case hasNetworkd:
+ res.Backend = BackendNetworkd
+ res.ChosenReason = "systemd-networkd detected (no NM/ConnMan). Using networkd for L3 and wired."
+ default:
+ res.Backend = BackendNone
+ if hasWpa {
+ res.ChosenReason = "No NM/ConnMan/iwd; wpa_supplicant present. Consider a wpa_supplicant path."
+ } else {
+ res.ChosenReason = "No known network manager bus names found."
+ }
+ }
+
+ return res, nil
+}
diff --git a/backend/internal/server/network/detect_test.go b/backend/internal/server/network/detect_test.go
new file mode 100644
index 00000000..56788604
--- /dev/null
+++ b/backend/internal/server/network/detect_test.go
@@ -0,0 +1,34 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBackendType_Constants(t *testing.T) {
+ assert.Equal(t, BackendType(0), BackendNone)
+ assert.Equal(t, BackendType(1), BackendNetworkManager)
+ assert.Equal(t, BackendType(2), BackendIwd)
+ assert.Equal(t, BackendType(3), BackendConnMan)
+ assert.Equal(t, BackendType(4), BackendNetworkd)
+}
+
+func TestDetectResult_HasNetworkdField(t *testing.T) {
+ result := &DetectResult{
+ Backend: BackendNetworkd,
+ HasNetworkd: true,
+ HasIwd: true,
+ }
+
+ assert.True(t, result.HasNetworkd)
+ assert.True(t, result.HasIwd)
+ assert.Equal(t, BackendNetworkd, result.Backend)
+}
+
+func TestDetectNetworkStack_Integration(t *testing.T) {
+ result, err := DetectNetworkStack()
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.NotEmpty(t, result.ChosenReason)
+}
diff --git a/backend/internal/server/network/handlers.go b/backend/internal/server/network/handlers.go
new file mode 100644
index 00000000..7bceedb2
--- /dev/null
+++ b/backend/internal/server/network/handlers.go
@@ -0,0 +1,487 @@
+package network
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ switch req.Method {
+ case "network.getState":
+ handleGetState(conn, req, manager)
+ case "network.wifi.scan":
+ handleScanWiFi(conn, req, manager)
+ case "network.wifi.networks":
+ handleGetWiFiNetworks(conn, req, manager)
+ case "network.wifi.connect":
+ handleConnectWiFi(conn, req, manager)
+ case "network.wifi.disconnect":
+ handleDisconnectWiFi(conn, req, manager)
+ case "network.wifi.forget":
+ handleForgetWiFi(conn, req, manager)
+ case "network.wifi.toggle":
+ handleToggleWiFi(conn, req, manager)
+ case "network.wifi.enable":
+ handleEnableWiFi(conn, req, manager)
+ case "network.wifi.disable":
+ handleDisableWiFi(conn, req, manager)
+ case "network.ethernet.connect.config":
+ handleConnectEthernetSpecificConfig(conn, req, manager)
+ case "network.ethernet.connect":
+ handleConnectEthernet(conn, req, manager)
+ case "network.ethernet.disconnect":
+ handleDisconnectEthernet(conn, req, manager)
+ case "network.preference.set":
+ handleSetPreference(conn, req, manager)
+ case "network.info":
+ handleGetNetworkInfo(conn, req, manager)
+ case "network.ethernet.info":
+ handleGetWiredNetworkInfo(conn, req, manager)
+ case "network.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "network.credentials.submit":
+ handleCredentialsSubmit(conn, req, manager)
+ case "network.credentials.cancel":
+ handleCredentialsCancel(conn, req, manager)
+ case "network.vpn.profiles":
+ handleListVPNProfiles(conn, req, manager)
+ case "network.vpn.active":
+ handleListActiveVPN(conn, req, manager)
+ case "network.vpn.connect":
+ handleConnectVPN(conn, req, manager)
+ case "network.vpn.disconnect":
+ handleDisconnectVPN(conn, req, manager)
+ case "network.vpn.disconnectAll":
+ handleDisconnectAllVPN(conn, req, manager)
+ case "network.vpn.clearCredentials":
+ handleClearVPNCredentials(conn, req, manager)
+ case "network.wifi.setAutoconnect":
+ handleSetWiFiAutoconnect(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleCredentialsSubmit(conn net.Conn, req Request, manager *Manager) {
+ token, ok := req.Params["token"].(string)
+ if !ok {
+ log.Warnf("handleCredentialsSubmit: missing or invalid token parameter")
+ models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
+ return
+ }
+
+ secretsRaw, ok := req.Params["secrets"].(map[string]interface{})
+ if !ok {
+ log.Warnf("handleCredentialsSubmit: missing or invalid secrets parameter")
+ models.RespondError(conn, req.ID, "missing or invalid 'secrets' parameter")
+ return
+ }
+
+ secrets := make(map[string]string)
+ for k, v := range secretsRaw {
+ if str, ok := v.(string); ok {
+ secrets[k] = str
+ }
+ }
+
+ save := true
+ if saveParam, ok := req.Params["save"].(bool); ok {
+ save = saveParam
+ }
+
+ if err := manager.SubmitCredentials(token, secrets, save); err != nil {
+ log.Warnf("handleCredentialsSubmit: failed to submit credentials: %v", err)
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ log.Infof("handleCredentialsSubmit: credentials submitted successfully")
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "credentials submitted"})
+}
+
+func handleCredentialsCancel(conn net.Conn, req Request, manager *Manager) {
+ token, ok := req.Params["token"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'token' parameter")
+ return
+ }
+
+ if err := manager.CancelCredentials(token); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "credentials cancelled"})
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleScanWiFi(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.ScanWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "scanning"})
+}
+
+func handleGetWiFiNetworks(conn net.Conn, req Request, manager *Manager) {
+ networks := manager.GetWiFiNetworks()
+ models.Respond(conn, req.ID, networks)
+}
+
+func handleConnectWiFi(conn net.Conn, req Request, manager *Manager) {
+ ssid, ok := req.Params["ssid"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'ssid' parameter")
+ return
+ }
+
+ var connReq ConnectionRequest
+ connReq.SSID = ssid
+
+ if password, ok := req.Params["password"].(string); ok {
+ connReq.Password = password
+ }
+ if username, ok := req.Params["username"].(string); ok {
+ connReq.Username = username
+ }
+
+ if interactive, ok := req.Params["interactive"].(bool); ok {
+ connReq.Interactive = interactive
+ } else {
+ state := manager.GetState()
+ alreadyConnected := state.WiFiConnected && state.WiFiSSID == ssid
+
+ if alreadyConnected {
+ connReq.Interactive = false
+ } else {
+ networkInfo, err := manager.GetNetworkInfo(ssid)
+ isSaved := err == nil && networkInfo.Saved
+
+ if isSaved {
+ connReq.Interactive = false
+ } else if err == nil && networkInfo.Secured && connReq.Password == "" && connReq.Username == "" {
+ connReq.Interactive = true
+ }
+ }
+ }
+
+ if anonymousIdentity, ok := req.Params["anonymousIdentity"].(string); ok {
+ connReq.AnonymousIdentity = anonymousIdentity
+ }
+ if domainSuffixMatch, ok := req.Params["domainSuffixMatch"].(string); ok {
+ connReq.DomainSuffixMatch = domainSuffixMatch
+ }
+
+ if err := manager.ConnectWiFi(connReq); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleDisconnectWiFi(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.DisconnectWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleForgetWiFi(conn net.Conn, req Request, manager *Manager) {
+ ssid, ok := req.Params["ssid"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'ssid' parameter")
+ return
+ }
+
+ if err := manager.ForgetWiFiNetwork(ssid); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "forgotten"})
+}
+
+func handleToggleWiFi(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.ToggleWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ state := manager.GetState()
+ models.Respond(conn, req.ID, map[string]bool{"enabled": state.WiFiEnabled})
+}
+
+func handleEnableWiFi(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.EnableWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, map[string]bool{"enabled": true})
+}
+
+func handleDisableWiFi(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.DisableWiFi(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, map[string]bool{"enabled": false})
+}
+
+func handleConnectEthernetSpecificConfig(conn net.Conn, req Request, manager *Manager) {
+ uuid, ok := req.Params["uuid"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'uuid' parameter")
+ return
+ }
+ if err := manager.activateConnection(uuid); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleConnectEthernet(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.ConnectEthernet(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleDisconnectEthernet(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.DisconnectEthernet(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleSetPreference(conn net.Conn, req Request, manager *Manager) {
+ preference, ok := req.Params["preference"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'preference' parameter")
+ return
+ }
+
+ if err := manager.SetConnectionPreference(ConnectionPreference(preference)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, map[string]string{"preference": preference})
+}
+
+func handleGetNetworkInfo(conn net.Conn, req Request, manager *Manager) {
+ ssid, ok := req.Params["ssid"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'ssid' parameter")
+ return
+ }
+
+ network, err := manager.GetNetworkInfoDetailed(ssid)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, network)
+}
+
+func handleGetWiredNetworkInfo(conn net.Conn, req Request, manager *Manager) {
+ uuid, ok := req.Params["uuid"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'uuid' parameter")
+ return
+ }
+
+ network, err := manager.GetWiredNetworkInfoDetailed(uuid)
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, network)
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := NetworkEvent{
+ Type: EventStateChanged,
+ Data: initialState,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[NetworkEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := NetworkEvent{
+ Type: EventStateChanged,
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[NetworkEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func handleListVPNProfiles(conn net.Conn, req Request, manager *Manager) {
+ profiles, err := manager.ListVPNProfiles()
+ if err != nil {
+ log.Warnf("handleListVPNProfiles: failed to list profiles: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list VPN profiles: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, profiles)
+}
+
+func handleListActiveVPN(conn net.Conn, req Request, manager *Manager) {
+ active, err := manager.ListActiveVPN()
+ if err != nil {
+ log.Warnf("handleListActiveVPN: failed to list active VPNs: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list active VPNs: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, active)
+}
+
+func handleConnectVPN(conn net.Conn, req Request, manager *Manager) {
+ uuidOrName, ok := req.Params["uuidOrName"].(string)
+ if !ok {
+ name, nameOk := req.Params["name"].(string)
+ uuid, uuidOk := req.Params["uuid"].(string)
+ if nameOk {
+ uuidOrName = name
+ } else if uuidOk {
+ uuidOrName = uuid
+ } else {
+ log.Warnf("handleConnectVPN: missing uuidOrName/name/uuid parameter")
+ models.RespondError(conn, req.ID, "missing 'uuidOrName', 'name', or 'uuid' parameter")
+ return
+ }
+ }
+
+ // Default to true - only allow one VPN connection at a time
+ singleActive := true
+ if sa, ok := req.Params["singleActive"].(bool); ok {
+ singleActive = sa
+ }
+
+ if err := manager.ConnectVPN(uuidOrName, singleActive); err != nil {
+ log.Warnf("handleConnectVPN: failed to connect: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to connect VPN: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "VPN connection initiated"})
+}
+
+func handleDisconnectVPN(conn net.Conn, req Request, manager *Manager) {
+ uuidOrName, ok := req.Params["uuidOrName"].(string)
+ if !ok {
+ name, nameOk := req.Params["name"].(string)
+ uuid, uuidOk := req.Params["uuid"].(string)
+ if nameOk {
+ uuidOrName = name
+ } else if uuidOk {
+ uuidOrName = uuid
+ } else {
+ log.Warnf("handleDisconnectVPN: missing uuidOrName/name/uuid parameter")
+ models.RespondError(conn, req.ID, "missing 'uuidOrName', 'name', or 'uuid' parameter")
+ return
+ }
+ }
+
+ if err := manager.DisconnectVPN(uuidOrName); err != nil {
+ log.Warnf("handleDisconnectVPN: failed to disconnect: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to disconnect VPN: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "VPN disconnected"})
+}
+
+func handleDisconnectAllVPN(conn net.Conn, req Request, manager *Manager) {
+ if err := manager.DisconnectAllVPN(); err != nil {
+ log.Warnf("handleDisconnectAllVPN: failed: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to disconnect all VPNs: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "All VPNs disconnected"})
+}
+
+func handleClearVPNCredentials(conn net.Conn, req Request, manager *Manager) {
+ uuidOrName, ok := req.Params["uuid"].(string)
+ if !ok {
+ uuidOrName, ok = req.Params["name"].(string)
+ }
+ if !ok {
+ uuidOrName, ok = req.Params["uuidOrName"].(string)
+ }
+ if !ok {
+ log.Warnf("handleClearVPNCredentials: missing uuidOrName/name/uuid parameter")
+ models.RespondError(conn, req.ID, "missing uuidOrName/name/uuid parameter")
+ return
+ }
+
+ if err := manager.ClearVPNCredentials(uuidOrName); err != nil {
+ log.Warnf("handleClearVPNCredentials: failed: %v", err)
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to clear VPN credentials: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "VPN credentials cleared"})
+}
+
+func handleSetWiFiAutoconnect(conn net.Conn, req Request, manager *Manager) {
+ ssid, ok := req.Params["ssid"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'ssid' parameter")
+ return
+ }
+
+ autoconnect, ok := req.Params["autoconnect"].(bool)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'autoconnect' parameter")
+ return
+ }
+
+ if err := manager.SetWiFiAutoconnect(ssid, autoconnect); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to set autoconnect: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "autoconnect updated"})
+}
diff --git a/backend/internal/server/network/handlers_test.go b/backend/internal/server/network/handlers_test.go
new file mode 100644
index 00000000..64ea4c17
--- /dev/null
+++ b/backend/internal/server/network/handlers_test.go
@@ -0,0 +1,263 @@
+package network
+
+import (
+ "bytes"
+ "encoding/json"
+ "net"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockNetConn struct {
+ net.Conn
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+ closed bool
+}
+
+func newMockNetConn() *mockNetConn {
+ return &mockNetConn{
+ readBuf: &bytes.Buffer{},
+ writeBuf: &bytes.Buffer{},
+ }
+}
+
+func (m *mockNetConn) Read(b []byte) (n int, err error) {
+ return m.readBuf.Read(b)
+}
+
+func (m *mockNetConn) Write(b []byte) (n int, err error) {
+ return m.writeBuf.Write(b)
+}
+
+func (m *mockNetConn) Close() error {
+ m.closed = true
+ return nil
+}
+
+func TestRespondError_Network(t *testing.T) {
+ conn := newMockNetConn()
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond_Network(t *testing.T) {
+ conn := newMockNetConn()
+ result := SuccessResult{Success: true, Message: "test"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[SuccessResult]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.True(t, resp.Result.Success)
+ assert.Equal(t, "test", resp.Result.Message)
+}
+
+func TestHandleGetState(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ WiFiConnected: true,
+ },
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "network.getState"}
+
+ handleGetState(conn, req, manager)
+
+ var resp models.Response[NetworkState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, StatusWiFi, resp.Result.NetworkStatus)
+ assert.Equal(t, "TestNetwork", resp.Result.WiFiSSID)
+}
+
+func TestHandleGetWiFiNetworks(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90},
+ {SSID: "Network2", Signal: 80},
+ },
+ },
+ }
+
+ conn := newMockNetConn()
+ req := Request{ID: 123, Method: "network.wifi.networks"}
+
+ handleGetWiFiNetworks(conn, req, manager)
+
+ var resp models.Response[[]WiFiNetwork]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Len(t, *resp.Result, 2)
+ assert.Equal(t, "Network1", (*resp.Result)[0].SSID)
+}
+
+func TestHandleConnectWiFi(t *testing.T) {
+ t.Run("missing ssid parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "network.wifi.connect",
+ Params: map[string]interface{}{},
+ }
+
+ handleConnectWiFi(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'ssid' parameter")
+ })
+}
+
+func TestHandleSetPreference(t *testing.T) {
+ t.Run("missing preference parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "network.preference.set",
+ Params: map[string]interface{}{},
+ }
+
+ handleSetPreference(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'preference' parameter")
+ })
+}
+
+func TestHandleGetNetworkInfo(t *testing.T) {
+ t.Run("missing ssid parameter", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ }
+
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "network.info",
+ Params: map[string]interface{}{},
+ }
+
+ handleGetNetworkInfo(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "missing or invalid 'ssid' parameter")
+ })
+}
+
+func TestHandleRequest(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ },
+ }
+
+ t.Run("unknown method", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "network.unknown",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[any]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Contains(t, resp.Error, "unknown method")
+ })
+
+ t.Run("valid method - getState", func(t *testing.T) {
+ conn := newMockNetConn()
+ req := Request{
+ ID: 123,
+ Method: "network.getState",
+ }
+
+ HandleRequest(conn, req, manager)
+
+ var resp models.Response[NetworkState]
+ err := json.NewDecoder(conn.writeBuf).Decode(&resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ })
+}
+
+func TestHandleSubscribe(t *testing.T) {
+ // This test is complex due to the streaming nature of subscriptions
+ // Better suited as an integration test
+ t.Skip("Subscription test requires connection lifecycle management - integration test needed")
+}
+
+func TestManager_Subscribe_Unsubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ subscribers: make(map[string]chan NetworkState),
+ }
+
+ t.Run("subscribe creates channel", func(t *testing.T) {
+ ch := manager.Subscribe("client1")
+ assert.NotNil(t, ch)
+ assert.Len(t, manager.subscribers, 1)
+ })
+
+ t.Run("unsubscribe removes channel", func(t *testing.T) {
+ manager.Unsubscribe("client1")
+ assert.Len(t, manager.subscribers, 0)
+ })
+
+ t.Run("unsubscribe non-existent client is safe", func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ manager.Unsubscribe("non-existent")
+ })
+ })
+}
diff --git a/backend/internal/server/network/helpers.go b/backend/internal/server/network/helpers.go
new file mode 100644
index 00000000..4669f52e
--- /dev/null
+++ b/backend/internal/server/network/helpers.go
@@ -0,0 +1,53 @@
+package network
+
+import "sort"
+
+func frequencyToChannel(freq uint32) uint32 {
+ if freq >= 2412 && freq <= 2484 {
+ if freq == 2484 {
+ return 14
+ }
+ return (freq-2412)/5 + 1
+ }
+
+ if freq >= 5170 && freq <= 5825 {
+ return (freq-5170)/5 + 34
+ }
+
+ if freq >= 5955 && freq <= 7115 {
+ return (freq-5955)/5 + 1
+ }
+
+ return 0
+}
+
+func sortWiFiNetworks(networks []WiFiNetwork) {
+ sort.Slice(networks, func(i, j int) bool {
+ if networks[i].Connected && !networks[j].Connected {
+ return true
+ }
+ if !networks[i].Connected && networks[j].Connected {
+ return false
+ }
+
+ if networks[i].Saved && !networks[j].Saved {
+ return true
+ }
+ if !networks[i].Saved && networks[j].Saved {
+ return false
+ }
+
+ if !networks[i].Secured && networks[j].Secured {
+ if networks[i].Signal >= 50 {
+ return true
+ }
+ }
+ if networks[i].Secured && !networks[j].Secured {
+ if networks[j].Signal >= 50 {
+ return false
+ }
+ }
+
+ return networks[i].Signal > networks[j].Signal
+ })
+}
diff --git a/backend/internal/server/network/manager.go b/backend/internal/server/network/manager.go
new file mode 100644
index 00000000..0c8b129a
--- /dev/null
+++ b/backend/internal/server/network/manager.go
@@ -0,0 +1,530 @@
+package network
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+)
+
+func NewManager() (*Manager, error) {
+ detection, err := DetectNetworkStack()
+ if err != nil {
+ return nil, fmt.Errorf("failed to detect network stack: %w", err)
+ }
+
+ log.Infof("Network backend detection: %s", detection.ChosenReason)
+
+ var backend Backend
+ switch detection.Backend {
+ case BackendNetworkManager:
+ nm, err := NewNetworkManagerBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create NetworkManager backend: %w", err)
+ }
+ backend = nm
+
+ case BackendIwd:
+ iwd, err := NewIWDBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create iwd backend: %w", err)
+ }
+ backend = iwd
+
+ case BackendNetworkd:
+ if detection.HasIwd && !detection.HasNM {
+ wifi, err := NewIWDBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create iwd backend: %w", err)
+ }
+ l3, err := NewSystemdNetworkdBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create networkd backend: %w", err)
+ }
+ hybrid, err := NewHybridIwdNetworkdBackend(wifi, l3)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create hybrid backend: %w", err)
+ }
+ backend = hybrid
+ } else {
+ nd, err := NewSystemdNetworkdBackend()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create networkd backend: %w", err)
+ }
+ backend = nd
+ }
+
+ default:
+ return nil, fmt.Errorf("no supported network backend found: %s", detection.ChosenReason)
+ }
+
+ m := &Manager{
+ backend: backend,
+ state: &NetworkState{
+ NetworkStatus: StatusDisconnected,
+ Preference: PreferenceAuto,
+ WiFiNetworks: []WiFiNetwork{},
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan NetworkState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ credentialSubscribers: make(map[string]chan CredentialPrompt),
+ credSubMutex: sync.RWMutex{},
+ }
+
+ broker := NewSubscriptionBroker(m.broadcastCredentialPrompt)
+ if err := backend.SetPromptBroker(broker); err != nil {
+ return nil, fmt.Errorf("failed to set prompt broker: %w", err)
+ }
+
+ if err := backend.Initialize(); err != nil {
+ return nil, fmt.Errorf("failed to initialize backend: %w", err)
+ }
+
+ if err := m.syncStateFromBackend(); err != nil {
+ return nil, fmt.Errorf("failed to sync initial state: %w", err)
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ if err := backend.StartMonitoring(m.onBackendStateChange); err != nil {
+ m.Close()
+ return nil, fmt.Errorf("failed to start monitoring: %w", err)
+ }
+
+ return m, nil
+}
+
+func (m *Manager) syncStateFromBackend() error {
+ backendState, err := m.backend.GetCurrentState()
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ m.state.Backend = backendState.Backend
+ m.state.NetworkStatus = backendState.NetworkStatus
+ m.state.EthernetIP = backendState.EthernetIP
+ m.state.EthernetDevice = backendState.EthernetDevice
+ m.state.EthernetConnected = backendState.EthernetConnected
+ m.state.EthernetConnectionUuid = backendState.EthernetConnectionUuid
+ m.state.WiFiIP = backendState.WiFiIP
+ m.state.WiFiDevice = backendState.WiFiDevice
+ m.state.WiFiConnected = backendState.WiFiConnected
+ m.state.WiFiEnabled = backendState.WiFiEnabled
+ m.state.WiFiSSID = backendState.WiFiSSID
+ m.state.WiFiBSSID = backendState.WiFiBSSID
+ m.state.WiFiSignal = backendState.WiFiSignal
+ m.state.WiFiNetworks = backendState.WiFiNetworks
+ m.state.WiredConnections = backendState.WiredConnections
+ m.state.VPNProfiles = backendState.VPNProfiles
+ m.state.VPNActive = backendState.VPNActive
+ m.state.IsConnecting = backendState.IsConnecting
+ m.state.ConnectingSSID = backendState.ConnectingSSID
+ m.state.LastError = backendState.LastError
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) onBackendStateChange() {
+ if err := m.syncStateFromBackend(); err != nil {
+ log.Errorf("failed to sync state from backend: %v", err)
+ }
+ m.notifySubscribers()
+}
+
+func signalChangeSignificant(old, new uint8) bool {
+ if old == 0 || new == 0 {
+ return true
+ }
+ diff := int(new) - int(old)
+ if diff < 0 {
+ diff = -diff
+ }
+ return diff >= 5
+}
+
+func (m *Manager) snapshotState() NetworkState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ s := *m.state
+ s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...)
+ s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...)
+ s.VPNProfiles = append([]VPNProfile(nil), m.state.VPNProfiles...)
+ s.VPNActive = append([]VPNActive(nil), m.state.VPNActive...)
+ return s
+}
+
+func stateChangedMeaningfully(old, new *NetworkState) bool {
+ if old.NetworkStatus != new.NetworkStatus {
+ return true
+ }
+ if old.Preference != new.Preference {
+ return true
+ }
+ if old.EthernetConnected != new.EthernetConnected {
+ return true
+ }
+ if old.EthernetIP != new.EthernetIP {
+ return true
+ }
+ if old.WiFiConnected != new.WiFiConnected {
+ return true
+ }
+ if old.WiFiEnabled != new.WiFiEnabled {
+ return true
+ }
+ if old.WiFiSSID != new.WiFiSSID {
+ return true
+ }
+ if old.WiFiBSSID != new.WiFiBSSID {
+ return true
+ }
+ if old.WiFiIP != new.WiFiIP {
+ return true
+ }
+ if !signalChangeSignificant(old.WiFiSignal, new.WiFiSignal) {
+ if old.WiFiSignal != new.WiFiSignal {
+ return false
+ }
+ } else if old.WiFiSignal != new.WiFiSignal {
+ return true
+ }
+ if old.IsConnecting != new.IsConnecting {
+ return true
+ }
+ if old.ConnectingSSID != new.ConnectingSSID {
+ return true
+ }
+ if old.LastError != new.LastError {
+ return true
+ }
+ if len(old.WiFiNetworks) != len(new.WiFiNetworks) {
+ return true
+ }
+ if len(old.WiredConnections) != len(new.WiredConnections) {
+ return true
+ }
+
+ for i := range old.WiFiNetworks {
+ oldNet := &old.WiFiNetworks[i]
+ newNet := &new.WiFiNetworks[i]
+ if oldNet.SSID != newNet.SSID {
+ return true
+ }
+ if oldNet.Connected != newNet.Connected {
+ return true
+ }
+ if oldNet.Saved != newNet.Saved {
+ return true
+ }
+ if oldNet.Autoconnect != newNet.Autoconnect {
+ return true
+ }
+ }
+
+ for i := range old.WiredConnections {
+ oldNet := &old.WiredConnections[i]
+ newNet := &new.WiredConnections[i]
+ if oldNet.ID != newNet.ID {
+ return true
+ }
+ if oldNet.IsActive != newNet.IsActive {
+ return true
+ }
+ }
+
+ // Check VPN profiles count
+ if len(old.VPNProfiles) != len(new.VPNProfiles) {
+ return true
+ }
+
+ // Check active VPN connections count or state
+ if len(old.VPNActive) != len(new.VPNActive) {
+ return true
+ }
+
+ // Check if any active VPN changed
+ for i := range old.VPNActive {
+ oldVPN := &old.VPNActive[i]
+ newVPN := &new.VPNActive[i]
+ if oldVPN.UUID != newVPN.UUID {
+ return true
+ }
+ if oldVPN.State != newVPN.State {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *Manager) GetState() NetworkState {
+ return m.snapshotState()
+}
+
+func (m *Manager) Subscribe(id string) chan NetworkState {
+ ch := make(chan NetworkState, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) SubscribeCredentials(id string) chan CredentialPrompt {
+ ch := make(chan CredentialPrompt, 16)
+ m.credSubMutex.Lock()
+ m.credentialSubscribers[id] = ch
+ m.credSubMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) UnsubscribeCredentials(id string) {
+ m.credSubMutex.Lock()
+ if ch, ok := m.credentialSubscribers[id]; ok {
+ close(ch)
+ delete(m.credentialSubscribers, id)
+ }
+ m.credSubMutex.Unlock()
+}
+
+func (m *Manager) broadcastCredentialPrompt(prompt CredentialPrompt) {
+ m.credSubMutex.RLock()
+ defer m.credSubMutex.RUnlock()
+
+ for _, ch := range m.credentialSubscribers {
+ select {
+ case ch <- prompt:
+ default:
+ }
+ }
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.subMutex.RLock()
+ if len(m.subscribers) == 0 {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChangedMeaningfully(m.lastNotifiedState, ¤tState) {
+ m.subMutex.RUnlock()
+ pending = false
+ continue
+ }
+
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ }
+ m.subMutex.RUnlock()
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) SetPromptBroker(broker PromptBroker) error {
+ return m.backend.SetPromptBroker(broker)
+}
+
+func (m *Manager) SubmitCredentials(token string, secrets map[string]string, save bool) error {
+ return m.backend.SubmitCredentials(token, secrets, save)
+}
+
+func (m *Manager) CancelCredentials(token string) error {
+ return m.backend.CancelCredentials(token)
+}
+
+func (m *Manager) GetPromptBroker() PromptBroker {
+ return m.backend.GetPromptBroker()
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+
+ if m.backend != nil {
+ m.backend.Close()
+ }
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan NetworkState)
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) ScanWiFi() error {
+ return m.backend.ScanWiFi()
+}
+
+func (m *Manager) GetWiFiNetworks() []WiFiNetwork {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ networks := make([]WiFiNetwork, len(m.state.WiFiNetworks))
+ copy(networks, m.state.WiFiNetworks)
+ return networks
+}
+
+func (m *Manager) GetNetworkInfo(ssid string) (*WiFiNetwork, error) {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ for _, network := range m.state.WiFiNetworks {
+ if network.SSID == ssid {
+ return &network, nil
+ }
+ }
+
+ return nil, fmt.Errorf("network not found: %s", ssid)
+}
+
+func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, error) {
+ return m.backend.GetWiFiNetworkDetails(ssid)
+}
+
+func (m *Manager) ToggleWiFi() error {
+ enabled, err := m.backend.GetWiFiEnabled()
+ if err != nil {
+ return fmt.Errorf("failed to get WiFi state: %w", err)
+ }
+
+ err = m.backend.SetWiFiEnabled(!enabled)
+ if err != nil {
+ return fmt.Errorf("failed to toggle WiFi: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) EnableWiFi() error {
+ err := m.backend.SetWiFiEnabled(true)
+ if err != nil {
+ return fmt.Errorf("failed to enable WiFi: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) DisableWiFi() error {
+ err := m.backend.SetWiFiEnabled(false)
+ if err != nil {
+ return fmt.Errorf("failed to disable WiFi: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) ConnectWiFi(req ConnectionRequest) error {
+ return m.backend.ConnectWiFi(req)
+}
+
+func (m *Manager) DisconnectWiFi() error {
+ return m.backend.DisconnectWiFi()
+}
+
+func (m *Manager) ForgetWiFiNetwork(ssid string) error {
+ return m.backend.ForgetWiFiNetwork(ssid)
+}
+
+func (m *Manager) GetWiredConfigs() []WiredConnection {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ configs := make([]WiredConnection, len(m.state.WiredConnections))
+ copy(configs, m.state.WiredConnections)
+ return configs
+}
+
+func (m *Manager) GetWiredNetworkInfoDetailed(uuid string) (*WiredNetworkInfoResponse, error) {
+ return m.backend.GetWiredNetworkDetails(uuid)
+}
+
+func (m *Manager) ConnectEthernet() error {
+ return m.backend.ConnectEthernet()
+}
+
+func (m *Manager) DisconnectEthernet() error {
+ return m.backend.DisconnectEthernet()
+}
+
+func (m *Manager) activateConnection(uuid string) error {
+ return m.backend.ActivateWiredConnection(uuid)
+}
+
+func (m *Manager) ListVPNProfiles() ([]VPNProfile, error) {
+ return m.backend.ListVPNProfiles()
+}
+
+func (m *Manager) ListActiveVPN() ([]VPNActive, error) {
+ return m.backend.ListActiveVPN()
+}
+
+func (m *Manager) ConnectVPN(uuidOrName string, singleActive bool) error {
+ return m.backend.ConnectVPN(uuidOrName, singleActive)
+}
+
+func (m *Manager) DisconnectVPN(uuidOrName string) error {
+ return m.backend.DisconnectVPN(uuidOrName)
+}
+
+func (m *Manager) DisconnectAllVPN() error {
+ return m.backend.DisconnectAllVPN()
+}
+
+func (m *Manager) ClearVPNCredentials(uuidOrName string) error {
+ return m.backend.ClearVPNCredentials(uuidOrName)
+}
+
+func (m *Manager) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
+ return m.backend.SetWiFiAutoconnect(ssid, autoconnect)
+}
diff --git a/backend/internal/server/network/manager_test.go b/backend/internal/server/network/manager_test.go
new file mode 100644
index 00000000..26c5f8f5
--- /dev/null
+++ b/backend/internal/server/network/manager_test.go
@@ -0,0 +1,209 @@
+package network
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetState(t *testing.T) {
+ state := &NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ WiFiConnected: true,
+ }
+
+ manager := &Manager{
+ state: state,
+ stateMutex: sync.RWMutex{},
+ }
+
+ result := manager.GetState()
+ assert.Equal(t, StatusWiFi, result.NetworkStatus)
+ assert.Equal(t, "TestNetwork", result.WiFiSSID)
+ assert.True(t, result.WiFiConnected)
+}
+
+func TestManager_NotifySubscribers(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan NetworkState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan NetworkState, 10)
+ manager.subMutex.Lock()
+ manager.subscribers["test-client"] = ch
+ manager.subMutex.Unlock()
+
+ manager.notifySubscribers()
+
+ select {
+ case state := <-ch:
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("did not receive state update")
+ }
+
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+}
+
+func TestManager_NotifySubscribers_Debounce(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ },
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan NetworkState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+ manager.notifierWg.Add(1)
+ go manager.notifier()
+
+ ch := make(chan NetworkState, 10)
+ manager.subMutex.Lock()
+ manager.subscribers["test-client"] = ch
+ manager.subMutex.Unlock()
+
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+ manager.notifySubscribers()
+
+ receivedCount := 0
+ timeout := time.After(200 * time.Millisecond)
+ for {
+ select {
+ case <-ch:
+ receivedCount++
+ case <-timeout:
+ assert.Equal(t, 1, receivedCount, "should receive exactly one debounced update")
+ close(manager.stopChan)
+ manager.notifierWg.Wait()
+ return
+ }
+ }
+}
+
+func TestManager_Close(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ stateMutex: sync.RWMutex{},
+ subscribers: make(map[string]chan NetworkState),
+ subMutex: sync.RWMutex{},
+ stopChan: make(chan struct{}),
+ }
+
+ ch1 := make(chan NetworkState, 1)
+ ch2 := make(chan NetworkState, 1)
+ manager.subMutex.Lock()
+ manager.subscribers["client1"] = ch1
+ manager.subscribers["client2"] = ch2
+ manager.subMutex.Unlock()
+
+ manager.Close()
+
+ select {
+ case <-manager.stopChan:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("stopChan not closed")
+ }
+
+ _, ok1 := <-ch1
+ _, ok2 := <-ch2
+ assert.False(t, ok1, "ch1 should be closed")
+ assert.False(t, ok2, "ch2 should be closed")
+
+ assert.Len(t, manager.subscribers, 0)
+}
+
+func TestManager_Subscribe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ subscribers: make(map[string]chan NetworkState),
+ subMutex: sync.RWMutex{},
+ }
+
+ ch := manager.Subscribe("test-client")
+ assert.NotNil(t, ch)
+ assert.Equal(t, 64, cap(ch))
+
+ manager.subMutex.RLock()
+ _, exists := manager.subscribers["test-client"]
+ manager.subMutex.RUnlock()
+ assert.True(t, exists)
+}
+
+func TestManager_Unsubscribe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{},
+ subscribers: make(map[string]chan NetworkState),
+ subMutex: sync.RWMutex{},
+ }
+
+ ch := manager.Subscribe("test-client")
+
+ manager.Unsubscribe("test-client")
+
+ _, ok := <-ch
+ assert.False(t, ok)
+
+ manager.subMutex.RLock()
+ _, exists := manager.subscribers["test-client"]
+ manager.subMutex.RUnlock()
+ assert.False(t, exists)
+}
+
+func TestNewManager(t *testing.T) {
+ t.Run("attempts to create manager", func(t *testing.T) {
+ manager, err := NewManager()
+ if err != nil {
+ assert.Nil(t, manager)
+ } else {
+ assert.NotNil(t, manager)
+ assert.NotNil(t, manager.state)
+ assert.NotNil(t, manager.subscribers)
+ assert.NotNil(t, manager.stopChan)
+
+ manager.Close()
+ }
+ })
+}
+
+func TestManager_GetState_ThreadSafe(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ },
+ stateMutex: sync.RWMutex{},
+ }
+
+ done := make(chan bool)
+ for i := 0; i < 10; i++ {
+ go func() {
+ state := manager.GetState()
+ assert.Equal(t, StatusWiFi, state.NetworkStatus)
+ done <- true
+ }()
+ }
+
+ for i := 0; i < 10; i++ {
+ select {
+ case <-done:
+ case <-time.After(1 * time.Second):
+ t.Fatal("timeout waiting for goroutines")
+ }
+ }
+}
diff --git a/backend/internal/server/network/monitor.go b/backend/internal/server/network/monitor.go
new file mode 100644
index 00000000..1ae2e9d5
--- /dev/null
+++ b/backend/internal/server/network/monitor.go
@@ -0,0 +1 @@
+package network
diff --git a/backend/internal/server/network/priority.go b/backend/internal/server/network/priority.go
new file mode 100644
index 00000000..d047bcc1
--- /dev/null
+++ b/backend/internal/server/network/priority.go
@@ -0,0 +1,138 @@
+package network
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/Wifx/gonetworkmanager/v2"
+)
+
+func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
+ switch pref {
+ case PreferenceWiFi, PreferenceEthernet, PreferenceAuto:
+ default:
+ return fmt.Errorf("invalid preference: %s", pref)
+ }
+
+ m.stateMutex.Lock()
+ m.state.Preference = pref
+ m.stateMutex.Unlock()
+
+ if _, ok := m.backend.(*NetworkManagerBackend); !ok {
+ m.notifySubscribers()
+ return nil
+ }
+
+ switch pref {
+ case PreferenceWiFi:
+ return m.prioritizeWiFi()
+ case PreferenceEthernet:
+ return m.prioritizeEthernet()
+ case PreferenceAuto:
+ return m.balancePriorities()
+ }
+
+ return nil
+}
+
+func (m *Manager) prioritizeWiFi() error {
+ if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil {
+ return err
+ }
+
+ if err := m.setConnectionMetrics("802-3-ethernet", 100); err != nil {
+ return err
+ }
+
+ m.notifySubscribers()
+ return nil
+}
+
+func (m *Manager) prioritizeEthernet() error {
+ if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil {
+ return err
+ }
+
+ if err := m.setConnectionMetrics("802-11-wireless", 100); err != nil {
+ return err
+ }
+
+ m.notifySubscribers()
+ return nil
+}
+
+func (m *Manager) balancePriorities() error {
+ if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil {
+ return err
+ }
+
+ if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil {
+ return err
+ }
+
+ m.notifySubscribers()
+ return nil
+}
+
+func (m *Manager) setConnectionMetrics(connType string, metric uint32) error {
+ settingsMgr, err := gonetworkmanager.NewSettings()
+ if err != nil {
+ return fmt.Errorf("failed to get settings: %w", err)
+ }
+
+ connections, err := settingsMgr.ListConnections()
+ if err != nil {
+ return fmt.Errorf("failed to get connections: %w", err)
+ }
+
+ for _, conn := range connections {
+ connSettings, err := conn.GetSettings()
+ if err != nil {
+ continue
+ }
+
+ if connMeta, ok := connSettings["connection"]; ok {
+ if cType, ok := connMeta["type"].(string); ok && cType == connType {
+ if connSettings["ipv4"] == nil {
+ connSettings["ipv4"] = make(map[string]interface{})
+ }
+ if ipv4Map := connSettings["ipv4"]; ipv4Map != nil {
+ ipv4Map["route-metric"] = int64(metric)
+ }
+
+ if connSettings["ipv6"] == nil {
+ connSettings["ipv6"] = make(map[string]interface{})
+ }
+ if ipv6Map := connSettings["ipv6"]; ipv6Map != nil {
+ ipv6Map["route-metric"] = int64(metric)
+ }
+
+ err = conn.Update(connSettings)
+ if err != nil {
+ continue
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) GetConnectionPreference() ConnectionPreference {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ return m.state.Preference
+}
+
+func (m *Manager) WasRecentlyFailed(ssid string) bool {
+ if nm, ok := m.backend.(*NetworkManagerBackend); ok {
+ nm.failedMutex.RLock()
+ defer nm.failedMutex.RUnlock()
+
+ if nm.lastFailedSSID == ssid {
+ elapsed := time.Now().Unix() - nm.lastFailedTime
+ return elapsed < 10
+ }
+ }
+ return false
+}
diff --git a/backend/internal/server/network/priority_test.go b/backend/internal/server/network/priority_test.go
new file mode 100644
index 00000000..c0c65e30
--- /dev/null
+++ b/backend/internal/server/network/priority_test.go
@@ -0,0 +1,50 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_SetConnectionPreference(t *testing.T) {
+ t.Run("invalid preference", func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ Preference: PreferenceAuto,
+ },
+ }
+
+ err := manager.SetConnectionPreference(ConnectionPreference("invalid"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid preference")
+ })
+}
+
+func TestManager_GetConnectionPreference(t *testing.T) {
+ tests := []struct {
+ name string
+ preference ConnectionPreference
+ }{
+ {"auto", PreferenceAuto},
+ {"wifi", PreferenceWiFi},
+ {"ethernet", PreferenceEthernet},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ Preference: tt.preference,
+ },
+ }
+
+ result := manager.GetConnectionPreference()
+ assert.Equal(t, tt.preference, result)
+ })
+ }
+}
+
+// Note: Full testing of priority operations would require mocking NetworkManager
+// D-Bus interfaces. The tests above cover the basic logic and error handling.
+// Integration tests would be needed for complete coverage of network connection
+// priority updates and reactivation.
diff --git a/backend/internal/server/network/subscription_broker.go b/backend/internal/server/network/subscription_broker.go
new file mode 100644
index 00000000..e1dad18b
--- /dev/null
+++ b/backend/internal/server/network/subscription_broker.go
@@ -0,0 +1,146 @@
+package network
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+)
+
+type SubscriptionBroker struct {
+ mu sync.RWMutex
+ pending map[string]chan PromptReply
+ requests map[string]PromptRequest
+ pathSettingToToken map[string]string
+ broadcastPrompt func(CredentialPrompt)
+}
+
+func NewSubscriptionBroker(broadcastPrompt func(CredentialPrompt)) PromptBroker {
+ return &SubscriptionBroker{
+ pending: make(map[string]chan PromptReply),
+ requests: make(map[string]PromptRequest),
+ pathSettingToToken: make(map[string]string),
+ broadcastPrompt: broadcastPrompt,
+ }
+}
+
+func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
+ pathSettingKey := fmt.Sprintf("%s:%s", req.ConnectionPath, req.SettingName)
+
+ b.mu.Lock()
+ existingToken, alreadyPending := b.pathSettingToToken[pathSettingKey]
+ b.mu.Unlock()
+
+ if alreadyPending {
+ log.Infof("[SubscriptionBroker] Duplicate prompt for %s, returning existing token", pathSettingKey)
+ return existingToken, nil
+ }
+
+ token, err := generateToken()
+ if err != nil {
+ return "", err
+ }
+
+ replyChan := make(chan PromptReply, 1)
+ b.mu.Lock()
+ b.pending[token] = replyChan
+ b.requests[token] = req
+ b.pathSettingToToken[pathSettingKey] = token
+ b.mu.Unlock()
+
+ if b.broadcastPrompt != nil {
+ prompt := CredentialPrompt{
+ Token: token,
+ Name: req.Name,
+ SSID: req.SSID,
+ ConnType: req.ConnType,
+ VpnService: req.VpnService,
+ Setting: req.SettingName,
+ Fields: req.Fields,
+ Hints: req.Hints,
+ Reason: req.Reason,
+ ConnectionId: req.ConnectionId,
+ ConnectionUuid: req.ConnectionUuid,
+ }
+ b.broadcastPrompt(prompt)
+ }
+
+ return token, nil
+}
+
+func (b *SubscriptionBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
+ b.mu.RLock()
+ replyChan, exists := b.pending[token]
+ b.mu.RUnlock()
+
+ if !exists {
+ return PromptReply{}, fmt.Errorf("unknown token: %s", token)
+ }
+
+ select {
+ case <-ctx.Done():
+ b.cleanup(token)
+ return PromptReply{}, errdefs.ErrSecretPromptTimeout
+ case reply := <-replyChan:
+ b.cleanup(token)
+ if reply.Cancel {
+ return reply, errdefs.ErrSecretPromptCancelled
+ }
+ return reply, nil
+ }
+}
+
+func (b *SubscriptionBroker) Resolve(token string, reply PromptReply) error {
+ b.mu.RLock()
+ replyChan, exists := b.pending[token]
+ b.mu.RUnlock()
+
+ if !exists {
+ log.Warnf("[SubscriptionBroker] Resolve: unknown or expired token: %s", token)
+ return fmt.Errorf("unknown or expired token: %s", token)
+ }
+
+ select {
+ case replyChan <- reply:
+ return nil
+ default:
+ log.Warnf("[SubscriptionBroker] Resolve: failed to deliver reply for token %s (channel full or closed)", token)
+ return fmt.Errorf("failed to deliver reply for token: %s", token)
+ }
+}
+
+func (b *SubscriptionBroker) cleanup(token string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ if req, exists := b.requests[token]; exists {
+ pathSettingKey := fmt.Sprintf("%s:%s", req.ConnectionPath, req.SettingName)
+ delete(b.pathSettingToToken, pathSettingKey)
+ }
+
+ delete(b.pending, token)
+ delete(b.requests, token)
+}
+
+func (b *SubscriptionBroker) Cancel(path string, setting string) error {
+ pathSettingKey := fmt.Sprintf("%s:%s", path, setting)
+
+ b.mu.Lock()
+ token, exists := b.pathSettingToToken[pathSettingKey]
+ b.mu.Unlock()
+
+ if !exists {
+ log.Infof("[SubscriptionBroker] Cancel: no pending prompt for %s", pathSettingKey)
+ return nil
+ }
+
+ log.Infof("[SubscriptionBroker] Cancelling prompt for %s (token=%s)", pathSettingKey, token)
+
+ reply := PromptReply{
+ Cancel: true,
+ }
+
+ return b.Resolve(token, reply)
+}
diff --git a/backend/internal/server/network/testing.go b/backend/internal/server/network/testing.go
new file mode 100644
index 00000000..f4ff197b
--- /dev/null
+++ b/backend/internal/server/network/testing.go
@@ -0,0 +1,15 @@
+package network
+
+// NewTestManager creates a Manager for testing with a provided backend
+func NewTestManager(backend Backend, state *NetworkState) *Manager {
+ if state == nil {
+ state = &NetworkState{}
+ }
+ return &Manager{
+ backend: backend,
+ state: state,
+ subscribers: make(map[string]chan NetworkState),
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ }
+}
diff --git a/backend/internal/server/network/types.go b/backend/internal/server/network/types.go
new file mode 100644
index 00000000..a46a9163
--- /dev/null
+++ b/backend/internal/server/network/types.go
@@ -0,0 +1,190 @@
+package network
+
+import (
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+)
+
+type NetworkStatus string
+
+const (
+ StatusDisconnected NetworkStatus = "disconnected"
+ StatusEthernet NetworkStatus = "ethernet"
+ StatusWiFi NetworkStatus = "wifi"
+ StatusVPN NetworkStatus = "vpn"
+)
+
+type ConnectionPreference string
+
+const (
+ PreferenceAuto ConnectionPreference = "auto"
+ PreferenceWiFi ConnectionPreference = "wifi"
+ PreferenceEthernet ConnectionPreference = "ethernet"
+)
+
+type WiFiNetwork struct {
+ SSID string `json:"ssid"`
+ BSSID string `json:"bssid"`
+ Signal uint8 `json:"signal"`
+ Secured bool `json:"secured"`
+ Enterprise bool `json:"enterprise"`
+ Connected bool `json:"connected"`
+ Saved bool `json:"saved"`
+ Autoconnect bool `json:"autoconnect"`
+ Frequency uint32 `json:"frequency"`
+ Mode string `json:"mode"`
+ Rate uint32 `json:"rate"`
+ Channel uint32 `json:"channel"`
+}
+
+type VPNProfile struct {
+ Name string `json:"name"`
+ UUID string `json:"uuid"`
+ Type string `json:"type"`
+ ServiceType string `json:"serviceType"`
+}
+
+type VPNActive struct {
+ Name string `json:"name"`
+ UUID string `json:"uuid"`
+ Device string `json:"device,omitempty"`
+ State string `json:"state,omitempty"`
+ Type string `json:"type"`
+ Plugin string `json:"serviceType"`
+}
+
+type VPNState struct {
+ Profiles []VPNProfile `json:"profiles"`
+ Active []VPNActive `json:"activeConnections"`
+}
+
+type NetworkState struct {
+ Backend string `json:"backend"`
+ NetworkStatus NetworkStatus `json:"networkStatus"`
+ Preference ConnectionPreference `json:"preference"`
+ EthernetIP string `json:"ethernetIP"`
+ EthernetDevice string `json:"ethernetDevice"`
+ EthernetConnected bool `json:"ethernetConnected"`
+ EthernetConnectionUuid string `json:"ethernetConnectionUuid"`
+ WiFiIP string `json:"wifiIP"`
+ WiFiDevice string `json:"wifiDevice"`
+ WiFiConnected bool `json:"wifiConnected"`
+ WiFiEnabled bool `json:"wifiEnabled"`
+ WiFiSSID string `json:"wifiSSID"`
+ WiFiBSSID string `json:"wifiBSSID"`
+ WiFiSignal uint8 `json:"wifiSignal"`
+ WiFiNetworks []WiFiNetwork `json:"wifiNetworks"`
+ WiredConnections []WiredConnection `json:"wiredConnections"`
+ VPNProfiles []VPNProfile `json:"vpnProfiles"`
+ VPNActive []VPNActive `json:"vpnActive"`
+ IsConnecting bool `json:"isConnecting"`
+ ConnectingSSID string `json:"connectingSSID"`
+ LastError string `json:"lastError"`
+}
+
+type ConnectionRequest struct {
+ SSID string `json:"ssid"`
+ Password string `json:"password,omitempty"`
+ Username string `json:"username,omitempty"`
+ AnonymousIdentity string `json:"anonymousIdentity,omitempty"`
+ DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"`
+ Interactive bool `json:"interactive,omitempty"`
+}
+
+type WiredConnection struct {
+ Path dbus.ObjectPath `json:"path"`
+ ID string `json:"id"`
+ UUID string `json:"uuid"`
+ Type string `json:"type"`
+ IsActive bool `json:"isActive"`
+}
+
+type PriorityUpdate struct {
+ Preference ConnectionPreference `json:"preference"`
+}
+
+type Manager struct {
+ backend Backend
+ state *NetworkState
+ stateMutex sync.RWMutex
+ subscribers map[string]chan NetworkState
+ subMutex sync.RWMutex
+ stopChan chan struct{}
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *NetworkState
+ credentialSubscribers map[string]chan CredentialPrompt
+ credSubMutex sync.RWMutex
+}
+
+type EventType string
+
+const (
+ EventStateChanged EventType = "state_changed"
+ EventNetworksUpdated EventType = "networks_updated"
+ EventConnecting EventType = "connecting"
+ EventConnected EventType = "connected"
+ EventDisconnected EventType = "disconnected"
+ EventError EventType = "error"
+)
+
+type NetworkEvent struct {
+ Type EventType `json:"type"`
+ Data NetworkState `json:"data"`
+}
+
+type PromptRequest struct {
+ Name string `json:"name"`
+ SSID string `json:"ssid"`
+ ConnType string `json:"connType"`
+ VpnService string `json:"vpnService"`
+ SettingName string `json:"setting"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Reason string `json:"reason"`
+ ConnectionId string `json:"connectionId"`
+ ConnectionUuid string `json:"connectionUuid"`
+ ConnectionPath string `json:"connectionPath"`
+}
+
+type PromptReply struct {
+ Secrets map[string]string `json:"secrets"`
+ Save bool `json:"save"`
+ Cancel bool `json:"cancel"`
+}
+
+type CredentialPrompt struct {
+ Token string `json:"token"`
+ Name string `json:"name"`
+ SSID string `json:"ssid"`
+ ConnType string `json:"connType"`
+ VpnService string `json:"vpnService"`
+ Setting string `json:"setting"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Reason string `json:"reason"`
+ ConnectionId string `json:"connectionId"`
+ ConnectionUuid string `json:"connectionUuid"`
+}
+
+type NetworkInfoResponse struct {
+ SSID string `json:"ssid"`
+ Bands []WiFiNetwork `json:"bands"`
+}
+
+type WiredNetworkInfoResponse struct {
+ UUID string `json:"uuid"`
+ IFace string `json:"iface"`
+ Driver string `json:"driver"`
+ HwAddr string `json:"hwAddr"`
+ Speed string `json:"speed"`
+ IPv4 WiredIPConfig `json:"IPv4s"`
+ IPv6 WiredIPConfig `json:"IPv6s"`
+}
+
+type WiredIPConfig struct {
+ IPs []string `json:"ips"`
+ Gateway string `json:"gateway"`
+ DNS string `json:"dns"`
+}
diff --git a/backend/internal/server/network/types_test.go b/backend/internal/server/network/types_test.go
new file mode 100644
index 00000000..1c3e0bff
--- /dev/null
+++ b/backend/internal/server/network/types_test.go
@@ -0,0 +1,178 @@
+package network
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNetworkStatus_Constants(t *testing.T) {
+ assert.Equal(t, NetworkStatus("disconnected"), StatusDisconnected)
+ assert.Equal(t, NetworkStatus("ethernet"), StatusEthernet)
+ assert.Equal(t, NetworkStatus("wifi"), StatusWiFi)
+}
+
+func TestConnectionPreference_Constants(t *testing.T) {
+ assert.Equal(t, ConnectionPreference("auto"), PreferenceAuto)
+ assert.Equal(t, ConnectionPreference("wifi"), PreferenceWiFi)
+ assert.Equal(t, ConnectionPreference("ethernet"), PreferenceEthernet)
+}
+
+func TestEventType_Constants(t *testing.T) {
+ assert.Equal(t, EventType("state_changed"), EventStateChanged)
+ assert.Equal(t, EventType("networks_updated"), EventNetworksUpdated)
+ assert.Equal(t, EventType("connecting"), EventConnecting)
+ assert.Equal(t, EventType("connected"), EventConnected)
+ assert.Equal(t, EventType("disconnected"), EventDisconnected)
+ assert.Equal(t, EventType("error"), EventError)
+}
+
+func TestWiFiNetwork_JSON(t *testing.T) {
+ network := WiFiNetwork{
+ SSID: "TestNetwork",
+ BSSID: "00:11:22:33:44:55",
+ Signal: 85,
+ Secured: true,
+ Enterprise: false,
+ Connected: true,
+ Saved: true,
+ Frequency: 2437,
+ Mode: "infrastructure",
+ Rate: 300,
+ Channel: 6,
+ }
+
+ data, err := json.Marshal(network)
+ require.NoError(t, err)
+
+ var decoded WiFiNetwork
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, network.SSID, decoded.SSID)
+ assert.Equal(t, network.BSSID, decoded.BSSID)
+ assert.Equal(t, network.Signal, decoded.Signal)
+ assert.Equal(t, network.Secured, decoded.Secured)
+ assert.Equal(t, network.Enterprise, decoded.Enterprise)
+ assert.Equal(t, network.Connected, decoded.Connected)
+ assert.Equal(t, network.Saved, decoded.Saved)
+ assert.Equal(t, network.Frequency, decoded.Frequency)
+ assert.Equal(t, network.Mode, decoded.Mode)
+ assert.Equal(t, network.Rate, decoded.Rate)
+ assert.Equal(t, network.Channel, decoded.Channel)
+}
+
+func TestNetworkState_JSON(t *testing.T) {
+ state := NetworkState{
+ NetworkStatus: StatusWiFi,
+ Preference: PreferenceAuto,
+ EthernetIP: "192.168.1.100",
+ EthernetDevice: "eth0",
+ EthernetConnected: false,
+ WiFiIP: "192.168.1.101",
+ WiFiDevice: "wlan0",
+ WiFiConnected: true,
+ WiFiEnabled: true,
+ WiFiSSID: "TestNetwork",
+ WiFiBSSID: "00:11:22:33:44:55",
+ WiFiSignal: 85,
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90},
+ {SSID: "Network2", Signal: 60},
+ },
+ IsConnecting: false,
+ ConnectingSSID: "",
+ LastError: "",
+ }
+
+ data, err := json.Marshal(state)
+ require.NoError(t, err)
+
+ var decoded NetworkState
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, state.NetworkStatus, decoded.NetworkStatus)
+ assert.Equal(t, state.Preference, decoded.Preference)
+ assert.Equal(t, state.WiFiIP, decoded.WiFiIP)
+ assert.Equal(t, state.WiFiSSID, decoded.WiFiSSID)
+ assert.Equal(t, len(state.WiFiNetworks), len(decoded.WiFiNetworks))
+}
+
+func TestConnectionRequest_JSON(t *testing.T) {
+ t.Run("with password", func(t *testing.T) {
+ req := ConnectionRequest{
+ SSID: "TestNetwork",
+ Password: "testpass123",
+ }
+
+ data, err := json.Marshal(req)
+ require.NoError(t, err)
+
+ var decoded ConnectionRequest
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, req.SSID, decoded.SSID)
+ assert.Equal(t, req.Password, decoded.Password)
+ assert.Empty(t, decoded.Username)
+ })
+
+ t.Run("with username and password (enterprise)", func(t *testing.T) {
+ req := ConnectionRequest{
+ SSID: "EnterpriseNetwork",
+ Password: "testpass123",
+ Username: "testuser",
+ }
+
+ data, err := json.Marshal(req)
+ require.NoError(t, err)
+
+ var decoded ConnectionRequest
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, req.SSID, decoded.SSID)
+ assert.Equal(t, req.Password, decoded.Password)
+ assert.Equal(t, req.Username, decoded.Username)
+ })
+}
+
+func TestPriorityUpdate_JSON(t *testing.T) {
+ update := PriorityUpdate{
+ Preference: PreferenceWiFi,
+ }
+
+ data, err := json.Marshal(update)
+ require.NoError(t, err)
+
+ var decoded PriorityUpdate
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, update.Preference, decoded.Preference)
+}
+
+func TestNetworkEvent_JSON(t *testing.T) {
+ event := NetworkEvent{
+ Type: EventStateChanged,
+ Data: NetworkState{
+ NetworkStatus: StatusWiFi,
+ WiFiSSID: "TestNetwork",
+ WiFiConnected: true,
+ },
+ }
+
+ data, err := json.Marshal(event)
+ require.NoError(t, err)
+
+ var decoded NetworkEvent
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, event.Type, decoded.Type)
+ assert.Equal(t, event.Data.NetworkStatus, decoded.Data.NetworkStatus)
+ assert.Equal(t, event.Data.WiFiSSID, decoded.Data.WiFiSSID)
+}
diff --git a/backend/internal/server/network/wifi_test.go b/backend/internal/server/network/wifi_test.go
new file mode 100644
index 00000000..2256b60c
--- /dev/null
+++ b/backend/internal/server/network/wifi_test.go
@@ -0,0 +1,148 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFrequencyToChannel(t *testing.T) {
+ tests := []struct {
+ name string
+ frequency uint32
+ channel uint32
+ }{
+ {"2.4 GHz channel 1", 2412, 1},
+ {"2.4 GHz channel 6", 2437, 6},
+ {"2.4 GHz channel 11", 2462, 11},
+ {"2.4 GHz channel 14", 2484, 14},
+ {"5 GHz channel 36", 5180, 36},
+ {"5 GHz channel 40", 5200, 40},
+ {"5 GHz channel 165", 5825, 165},
+ {"6 GHz channel 1", 5955, 1},
+ {"6 GHz channel 233", 7115, 233},
+ {"Unknown frequency", 1000, 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := frequencyToChannel(tt.frequency)
+ assert.Equal(t, tt.channel, result)
+ })
+ }
+}
+
+func TestSortWiFiNetworks(t *testing.T) {
+ t.Run("connected network comes first", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "Network1", Signal: 90, Connected: false},
+ {SSID: "Network2", Signal: 80, Connected: true},
+ {SSID: "Network3", Signal: 70, Connected: false},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "Network2", networks[0].SSID)
+ assert.True(t, networks[0].Connected)
+ })
+
+ t.Run("sorts by signal strength", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "Weak", Signal: 40, Secured: true},
+ {SSID: "Strong", Signal: 90, Secured: true},
+ {SSID: "Medium", Signal: 60, Secured: true},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "Strong", networks[0].SSID)
+ assert.Equal(t, "Medium", networks[1].SSID)
+ assert.Equal(t, "Weak", networks[2].SSID)
+ })
+
+ t.Run("prioritizes open networks with good signal", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "SecureWeak", Signal: 40, Secured: true},
+ {SSID: "OpenStrong", Signal: 60, Secured: false},
+ {SSID: "SecureStrong", Signal: 90, Secured: true},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "OpenStrong", networks[0].SSID)
+
+ openIdx := -1
+ weakSecureIdx := -1
+ for i, n := range networks {
+ if n.SSID == "OpenStrong" {
+ openIdx = i
+ }
+ if n.SSID == "SecureWeak" {
+ weakSecureIdx = i
+ }
+ }
+ assert.Less(t, openIdx, weakSecureIdx, "OpenStrong should come before SecureWeak")
+ })
+
+ t.Run("prioritizes saved networks after connected", func(t *testing.T) {
+ networks := []WiFiNetwork{
+ {SSID: "UnsavedStrong", Signal: 95, Saved: false},
+ {SSID: "SavedMedium", Signal: 60, Saved: true},
+ {SSID: "SavedWeak", Signal: 50, Saved: true},
+ {SSID: "UnsavedMedium", Signal: 70, Saved: false},
+ }
+
+ sortWiFiNetworks(networks)
+
+ assert.Equal(t, "SavedMedium", networks[0].SSID)
+ assert.Equal(t, "SavedWeak", networks[1].SSID)
+ assert.Equal(t, "UnsavedStrong", networks[2].SSID)
+ assert.Equal(t, "UnsavedMedium", networks[3].SSID)
+ })
+}
+
+func TestManager_GetWiFiNetworks(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90},
+ {SSID: "Network2", Signal: 80},
+ },
+ },
+ }
+
+ networks := manager.GetWiFiNetworks()
+
+ assert.Len(t, networks, 2)
+ assert.Equal(t, "Network1", networks[0].SSID)
+ assert.Equal(t, "Network2", networks[1].SSID)
+
+ networks[0].SSID = "Modified"
+ assert.Equal(t, "Network1", manager.state.WiFiNetworks[0].SSID)
+}
+
+func TestManager_GetNetworkInfo(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ WiFiNetworks: []WiFiNetwork{
+ {SSID: "Network1", Signal: 90, BSSID: "00:11:22:33:44:55"},
+ {SSID: "Network2", Signal: 80, BSSID: "AA:BB:CC:DD:EE:FF"},
+ },
+ },
+ }
+
+ t.Run("finds existing network", func(t *testing.T) {
+ network, err := manager.GetNetworkInfo("Network1")
+ assert.NoError(t, err)
+ assert.NotNil(t, network)
+ assert.Equal(t, "Network1", network.SSID)
+ assert.Equal(t, uint8(90), network.Signal)
+ })
+
+ t.Run("returns error for non-existent network", func(t *testing.T) {
+ network, err := manager.GetNetworkInfo("NonExistent")
+ assert.Error(t, err)
+ assert.Nil(t, network)
+ assert.Contains(t, err.Error(), "network not found")
+ })
+}
diff --git a/backend/internal/server/network/wired_test.go b/backend/internal/server/network/wired_test.go
new file mode 100644
index 00000000..175b3439
--- /dev/null
+++ b/backend/internal/server/network/wired_test.go
@@ -0,0 +1,23 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManager_GetWiredConfigs(t *testing.T) {
+ manager := &Manager{
+ state: &NetworkState{
+ EthernetConnected: true,
+ WiredConnections: []WiredConnection{
+ {ID: "Test", IsActive: true},
+ },
+ },
+ }
+
+ configs := manager.GetWiredConfigs()
+
+ assert.Len(t, configs, 1)
+ assert.Equal(t, "Test", configs[0].ID)
+}
diff --git a/backend/internal/server/plugins/handlers.go b/backend/internal/server/plugins/handlers.go
new file mode 100644
index 00000000..7a9cc605
--- /dev/null
+++ b/backend/internal/server/plugins/handlers.go
@@ -0,0 +1,27 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request) {
+ switch req.Method {
+ case "plugins.list":
+ HandleList(conn, req)
+ case "plugins.listInstalled":
+ HandleListInstalled(conn, req)
+ case "plugins.install":
+ HandleInstall(conn, req)
+ case "plugins.uninstall":
+ HandleUninstall(conn, req)
+ case "plugins.update":
+ HandleUpdate(conn, req)
+ case "plugins.search":
+ HandleSearch(conn, req)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
diff --git a/backend/internal/server/plugins/handlers_test.go b/backend/internal/server/plugins/handlers_test.go
new file mode 100644
index 00000000..08bf3dcd
--- /dev/null
+++ b/backend/internal/server/plugins/handlers_test.go
@@ -0,0 +1,196 @@
+package plugins
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/mocks/net"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestHandleList(t *testing.T) {
+ conn := net.NewMockConn(t)
+ conn.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.list",
+ Params: map[string]interface{}{},
+ }
+
+ HandleList(conn, req)
+}
+
+func TestHandleListInstalled(t *testing.T) {
+ conn := net.NewMockConn(t)
+ conn.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.listInstalled",
+ Params: map[string]interface{}{},
+ }
+
+ HandleListInstalled(conn, req)
+}
+
+func TestHandleInstallMissingName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.install",
+ Params: map[string]interface{}{},
+ }
+
+ HandleInstall(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+ assert.Contains(t, resp.Error, "missing or invalid 'name' parameter")
+}
+
+func TestHandleInstallInvalidName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.install",
+ Params: map[string]interface{}{
+ "name": 123,
+ },
+ }
+
+ HandleInstall(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestHandleUninstallMissingName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.uninstall",
+ Params: map[string]interface{}{},
+ }
+
+ HandleUninstall(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestHandleUpdateMissingName(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.update",
+ Params: map[string]interface{}{},
+ }
+
+ HandleUpdate(conn, req)
+
+ var resp models.Response[SuccessResult]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestHandleSearchMissingQuery(t *testing.T) {
+ conn := net.NewMockConn(t)
+ var written []byte
+ conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
+ written = b
+ return len(b), nil
+ }).Maybe()
+
+ req := models.Request{
+ ID: 123,
+ Method: "plugins.search",
+ Params: map[string]interface{}{},
+ }
+
+ HandleSearch(conn, req)
+
+ var resp models.Response[[]PluginInfo]
+ err := json.Unmarshal(written, &resp)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, resp.Error)
+}
+
+func TestSortPluginInfoByFirstParty(t *testing.T) {
+ plugins := []PluginInfo{
+ {Name: "third-party", Repo: "https://github.com/other/test"},
+ {Name: "first-party", Repo: "https://github.com/AvengeMedia/test"},
+ }
+
+ SortPluginInfoByFirstParty(plugins)
+
+ assert.Equal(t, "first-party", plugins[0].Name)
+ assert.Equal(t, "third-party", plugins[1].Name)
+}
+
+func TestPluginInfoJSON(t *testing.T) {
+ info := PluginInfo{
+ Name: "test",
+ Description: "test description",
+ Installed: true,
+ FirstParty: true,
+ }
+
+ data, err := json.Marshal(info)
+ assert.NoError(t, err)
+
+ var unmarshaled PluginInfo
+ err = json.Unmarshal(data, &unmarshaled)
+ assert.NoError(t, err)
+ assert.Equal(t, info.Name, unmarshaled.Name)
+ assert.Equal(t, info.Installed, unmarshaled.Installed)
+}
+
+func TestSuccessResult(t *testing.T) {
+ result := SuccessResult{
+ Success: true,
+ Message: "test message",
+ }
+
+ data, err := json.Marshal(result)
+ assert.NoError(t, err)
+
+ var unmarshaled SuccessResult
+ err = json.Unmarshal(data, &unmarshaled)
+ assert.NoError(t, err)
+ assert.True(t, unmarshaled.Success)
+ assert.Equal(t, "test message", unmarshaled.Message)
+}
diff --git a/backend/internal/server/plugins/install.go b/backend/internal/server/plugins/install.go
new file mode 100644
index 00000000..4c91a709
--- /dev/null
+++ b/backend/internal/server/plugins/install.go
@@ -0,0 +1,69 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleInstall(conn net.Conn, req models.Request) {
+ idOrName, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ // 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 {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", idOrName))
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ if err := manager.Install(*plugin); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to install plugin: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin installed: %s", plugin.Name),
+ })
+}
diff --git a/backend/internal/server/plugins/list.go b/backend/internal/server/plugins/list.go
new file mode 100644
index 00000000..63cc8414
--- /dev/null
+++ b/backend/internal/server/plugins/list.go
@@ -0,0 +1,51 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleList(conn net.Conn, req models.Request) {
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]PluginInfo, len(pluginList))
+ for i, p := range pluginList {
+ installed, _ := manager.IsInstalled(p)
+ result[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,
+ Installed: installed,
+ FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/backend/internal/server/plugins/list_installed.go b/backend/internal/server/plugins/list_installed.go
new file mode 100644
index 00000000..4d7de8cc
--- /dev/null
+++ b/backend/internal/server/plugins/list_installed.go
@@ -0,0 +1,76 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleListInstalled(conn net.Conn, req models.Request) {
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ installedNames, err := manager.ListInstalled()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list installed plugins: %v", err))
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ allPlugins, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ pluginMap := make(map[string]plugins.Plugin)
+ for _, p := range allPlugins {
+ pluginMap[p.ID] = p
+ }
+
+ result := make([]PluginInfo, 0, len(installedNames))
+ for _, id := range installedNames {
+ if plugin, ok := pluginMap[id]; ok {
+ hasUpdate := false
+ if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil {
+ hasUpdate = hasUpdates
+ }
+
+ result = append(result, PluginInfo{
+ 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,
+ FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"),
+ HasUpdate: hasUpdate,
+ })
+ } else {
+ result = append(result, PluginInfo{
+ ID: id,
+ Name: id,
+ Note: "not in registry",
+ })
+ }
+ }
+
+ SortPluginInfoByFirstParty(result)
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/backend/internal/server/plugins/search.go b/backend/internal/server/plugins/search.go
new file mode 100644
index 00000000..2f103e5f
--- /dev/null
+++ b/backend/internal/server/plugins/search.go
@@ -0,0 +1,73 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleSearch(conn net.Conn, req models.Request) {
+ query, ok := req.Params["query"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'query' parameter")
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ searchResults := plugins.FuzzySearch(query, pluginList)
+
+ if category, ok := req.Params["category"].(string); ok && category != "" {
+ searchResults = plugins.FilterByCategory(category, searchResults)
+ }
+
+ if compositor, ok := req.Params["compositor"].(string); ok && compositor != "" {
+ searchResults = plugins.FilterByCompositor(compositor, searchResults)
+ }
+
+ if capability, ok := req.Params["capability"].(string); ok && capability != "" {
+ searchResults = plugins.FilterByCapability(capability, searchResults)
+ }
+
+ searchResults = plugins.SortByFirstParty(searchResults)
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]PluginInfo, len(searchResults))
+ for i, p := range searchResults {
+ installed, _ := manager.IsInstalled(p)
+ result[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,
+ Installed: installed,
+ FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/backend/internal/server/plugins/types.go b/backend/internal/server/plugins/types.go
new file mode 100644
index 00000000..232a258b
--- /dev/null
+++ b/backend/internal/server/plugins/types.go
@@ -0,0 +1,23 @@
+package plugins
+
+type PluginInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Category string `json:"category,omitempty"`
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ Repo string `json:"repo,omitempty"`
+ Path string `json:"path,omitempty"`
+ Capabilities []string `json:"capabilities,omitempty"`
+ Compositors []string `json:"compositors,omitempty"`
+ Dependencies []string `json:"dependencies,omitempty"`
+ Installed bool `json:"installed,omitempty"`
+ FirstParty bool `json:"firstParty,omitempty"`
+ Note string `json:"note,omitempty"`
+ HasUpdate bool `json:"hasUpdate,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
diff --git a/backend/internal/server/plugins/uninstall.go b/backend/internal/server/plugins/uninstall.go
new file mode 100644
index 00000000..a122b706
--- /dev/null
+++ b/backend/internal/server/plugins/uninstall.go
@@ -0,0 +1,69 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleUninstall(conn net.Conn, req models.Request) {
+ name, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ var plugin *plugins.Plugin
+ for _, p := range pluginList {
+ if p.Name == name {
+ plugin = &p
+ break
+ }
+ }
+
+ if plugin == nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ installed, err := manager.IsInstalled(*plugin)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
+ return
+ }
+
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
+ return
+ }
+
+ if err := manager.Uninstall(*plugin); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall plugin: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin uninstalled: %s", name),
+ })
+}
diff --git a/backend/internal/server/plugins/update.go b/backend/internal/server/plugins/update.go
new file mode 100644
index 00000000..09a77d5d
--- /dev/null
+++ b/backend/internal/server/plugins/update.go
@@ -0,0 +1,69 @@
+package plugins
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+func HandleUpdate(conn net.Conn, req models.Request) {
+ name, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ registry, err := plugins.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ pluginList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err))
+ return
+ }
+
+ var plugin *plugins.Plugin
+ for _, p := range pluginList {
+ if p.Name == name {
+ plugin = &p
+ break
+ }
+ }
+
+ if plugin == nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name))
+ return
+ }
+
+ manager, err := plugins.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ installed, err := manager.IsInstalled(*plugin)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err))
+ return
+ }
+
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name))
+ return
+ }
+
+ if err := manager.Update(*plugin); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to update plugin: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("plugin updated: %s", name),
+ })
+}
diff --git a/backend/internal/server/plugins/utils.go b/backend/internal/server/plugins/utils.go
new file mode 100644
index 00000000..8b89f440
--- /dev/null
+++ b/backend/internal/server/plugins/utils.go
@@ -0,0 +1,17 @@
+package plugins
+
+import (
+ "sort"
+ "strings"
+)
+
+func SortPluginInfoByFirstParty(pluginInfos []PluginInfo) {
+ sort.SliceStable(pluginInfos, func(i, j int) bool {
+ isFirstPartyI := strings.HasPrefix(pluginInfos[i].Repo, "https://github.com/AvengeMedia")
+ isFirstPartyJ := strings.HasPrefix(pluginInfos[j].Repo, "https://github.com/AvengeMedia")
+ if isFirstPartyI != isFirstPartyJ {
+ return isFirstPartyI
+ }
+ return false
+ })
+}
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
new file mode 100644
index 00000000..a9b47896
--- /dev/null
+++ b/backend/internal/server/router.go
@@ -0,0 +1,179 @@
+package server
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/bluez"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/brightness"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/cups"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/dwl"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/extworkspace"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/freedesktop"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/loginctl"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/network"
+ serverPlugins "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/wayland"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/wlroutput"
+)
+
+func RouteRequest(conn net.Conn, req models.Request) {
+ if strings.HasPrefix(req.Method, "network.") {
+ if networkManager == nil {
+ models.RespondError(conn, req.ID, "network manager not initialized")
+ return
+ }
+ netReq := network.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ network.HandleRequest(conn, netReq, networkManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "plugins.") {
+ serverPlugins.HandleRequest(conn, req)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "loginctl.") {
+ if loginctlManager == nil {
+ models.RespondError(conn, req.ID, "loginctl manager not initialized")
+ return
+ }
+ loginReq := loginctl.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ loginctl.HandleRequest(conn, loginReq, loginctlManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "freedesktop.") {
+ if freedesktopManager == nil {
+ models.RespondError(conn, req.ID, "freedesktop manager not initialized")
+ return
+ }
+ freedeskReq := freedesktop.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ freedesktop.HandleRequest(conn, freedeskReq, freedesktopManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "wayland.") {
+ if waylandManager == nil {
+ models.RespondError(conn, req.ID, "wayland manager not initialized")
+ return
+ }
+ waylandReq := wayland.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ wayland.HandleRequest(conn, waylandReq, waylandManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "bluetooth.") {
+ if bluezManager == nil {
+ models.RespondError(conn, req.ID, "bluetooth manager not initialized")
+ return
+ }
+ bluezReq := bluez.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ bluez.HandleRequest(conn, bluezReq, bluezManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "cups.") {
+ if cupsManager == nil {
+ models.RespondError(conn, req.ID, "CUPS manager not initialized")
+ return
+ }
+ cupsReq := cups.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ cups.HandleRequest(conn, cupsReq, cupsManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "dwl.") {
+ if dwlManager == nil {
+ models.RespondError(conn, req.ID, "dwl manager not initialized")
+ return
+ }
+ dwlReq := dwl.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ dwl.HandleRequest(conn, dwlReq, dwlManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "brightness.") {
+ if brightnessManager == nil {
+ models.RespondError(conn, req.ID, "brightness manager not initialized")
+ return
+ }
+ brightnessReq := brightness.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ brightness.HandleRequest(conn, brightnessReq, brightnessManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "extworkspace.") {
+ if extWorkspaceManager == nil {
+ models.RespondError(conn, req.ID, "extworkspace manager not initialized")
+ return
+ }
+ extWorkspaceReq := extworkspace.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ extworkspace.HandleRequest(conn, extWorkspaceReq, extWorkspaceManager)
+ return
+ }
+
+ if strings.HasPrefix(req.Method, "wlroutput.") {
+ if wlrOutputManager == nil {
+ models.RespondError(conn, req.ID, "wlroutput manager not initialized")
+ return
+ }
+ wlrOutputReq := wlroutput.Request{
+ ID: req.ID,
+ Method: req.Method,
+ Params: req.Params,
+ }
+ wlroutput.HandleRequest(conn, wlrOutputReq, wlrOutputManager)
+ return
+ }
+
+ switch req.Method {
+ case "ping":
+ models.Respond(conn, req.ID, "pong")
+ case "getServerInfo":
+ info := getServerInfo()
+ models.Respond(conn, req.ID, info)
+ case "subscribe":
+ handleSubscribe(conn, req)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go
new file mode 100644
index 00000000..0cdfdd49
--- /dev/null
+++ b/backend/internal/server/server.go
@@ -0,0 +1,1239 @@
+package server
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/bluez"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/brightness"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/cups"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/dwl"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/extworkspace"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/freedesktop"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/loginctl"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/network"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/wayland"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/wlcontext"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/wlroutput"
+)
+
+const APIVersion = 17
+
+type Capabilities struct {
+ Capabilities []string `json:"capabilities"`
+}
+
+type ServerInfo struct {
+ APIVersion int `json:"apiVersion"`
+ Capabilities []string `json:"capabilities"`
+}
+
+type ServiceEvent struct {
+ Service string `json:"service"`
+ Data interface{} `json:"data"`
+}
+
+var networkManager *network.Manager
+var loginctlManager *loginctl.Manager
+var freedesktopManager *freedesktop.Manager
+var waylandManager *wayland.Manager
+var bluezManager *bluez.Manager
+var cupsManager *cups.Manager
+var dwlManager *dwl.Manager
+var extWorkspaceManager *extworkspace.Manager
+var brightnessManager *brightness.Manager
+var wlrOutputManager *wlroutput.Manager
+var wlContext *wlcontext.SharedContext
+
+var capabilitySubscribers = make(map[string]chan ServerInfo)
+var capabilityMutex sync.RWMutex
+
+var cupsSubscribers = make(map[string]bool)
+var cupsSubscribersMutex sync.Mutex
+
+func getSocketDir() string {
+ if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
+ return runtime
+ }
+
+ if os.Getuid() == 0 {
+ if _, err := os.Stat("/run"); err == nil {
+ return "/run/dankdots"
+ }
+ return "/var/run/dankdots"
+ }
+
+ return os.TempDir()
+}
+
+func GetSocketPath() string {
+ return filepath.Join(getSocketDir(), fmt.Sprintf("danklinux-%d.sock", os.Getpid()))
+}
+
+func cleanupStaleSockets() {
+ dir := getSocketDir()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+
+ for _, entry := range entries {
+ if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".sock") {
+ continue
+ }
+
+ pidStr := strings.TrimPrefix(entry.Name(), "danklinux-")
+ pidStr = strings.TrimSuffix(pidStr, ".sock")
+ pid, err := strconv.Atoi(pidStr)
+ if err != nil {
+ continue
+ }
+
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ socketPath := filepath.Join(dir, entry.Name())
+ os.Remove(socketPath)
+ log.Debugf("Removed stale socket: %s", socketPath)
+ continue
+ }
+
+ err = process.Signal(syscall.Signal(0))
+ if err != nil {
+ socketPath := filepath.Join(dir, entry.Name())
+ os.Remove(socketPath)
+ log.Debugf("Removed stale socket: %s", socketPath)
+ }
+ }
+}
+
+func InitializeNetworkManager() error {
+ manager, err := network.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize network manager: %v", err)
+ return err
+ }
+
+ networkManager = manager
+
+ log.Info("Network manager initialized")
+ return nil
+}
+
+func InitializeLoginctlManager() error {
+ manager, err := loginctl.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize loginctl manager: %v", err)
+ return err
+ }
+
+ loginctlManager = manager
+
+ log.Info("Loginctl manager initialized")
+ return nil
+}
+
+func InitializeFreedeskManager() error {
+ manager, err := freedesktop.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize freedesktop manager: %v", err)
+ return err
+ }
+
+ freedesktopManager = manager
+
+ log.Info("Freedesktop manager initialized")
+ return nil
+}
+
+func InitializeWaylandManager() error {
+ log.Info("Attempting to initialize Wayland gamma control...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ config := wayland.DefaultConfig()
+ manager, err := wayland.NewManager(wlContext.Display(), config)
+ if err != nil {
+ log.Errorf("Failed to initialize wayland manager: %v", err)
+ return err
+ }
+
+ waylandManager = manager
+
+ log.Info("Wayland gamma control initialized successfully")
+ return nil
+}
+
+func InitializeBluezManager() error {
+ manager, err := bluez.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize bluez manager: %v", err)
+ return err
+ }
+
+ bluezManager = manager
+
+ log.Info("Bluez manager initialized")
+ return nil
+}
+
+func InitializeCupsManager() error {
+ manager, err := cups.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize cups manager: %v", err)
+ return err
+ }
+
+ cupsManager = manager
+
+ log.Info("CUPS manager initialized")
+ return nil
+}
+
+func InitializeDwlManager() error {
+ log.Info("Attempting to initialize DWL IPC...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ manager, err := dwl.NewManager(wlContext.Display())
+ if err != nil {
+ log.Debug("Failed to initialize dwl manager: %v", err)
+ return err
+ }
+
+ dwlManager = manager
+
+ log.Info("DWL IPC initialized successfully")
+ return nil
+}
+
+func InitializeBrightnessManager() error {
+ manager, err := brightness.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize brightness manager: %v", err)
+ return err
+ }
+
+ brightnessManager = manager
+
+ log.Info("Brightness manager initialized")
+ return nil
+}
+
+func InitializeExtWorkspaceManager() error {
+ log.Info("Attempting to initialize ExtWorkspace...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ manager, err := extworkspace.NewManager(wlContext.Display())
+ if err != nil {
+ log.Debug("Failed to initialize extworkspace manager: %v", err)
+ return err
+ }
+
+ extWorkspaceManager = manager
+
+ log.Info("ExtWorkspace initialized successfully")
+ return nil
+}
+
+func InitializeWlrOutputManager() error {
+ log.Info("Attempting to initialize WlrOutput management...")
+
+ if wlContext == nil {
+ ctx, err := wlcontext.New()
+ if err != nil {
+ log.Errorf("Failed to create shared Wayland context: %v", err)
+ return err
+ }
+ wlContext = ctx
+ }
+
+ manager, err := wlroutput.NewManager(wlContext.Display())
+ if err != nil {
+ log.Debug("Failed to initialize wlroutput manager: %v", err)
+ return err
+ }
+
+ wlrOutputManager = manager
+
+ log.Info("WlrOutput management initialized successfully")
+ return nil
+}
+
+func handleConnection(conn net.Conn) {
+ defer conn.Close()
+
+ caps := getCapabilities()
+ capsData, _ := json.Marshal(caps)
+ conn.Write(capsData)
+ conn.Write([]byte("\n"))
+
+ scanner := bufio.NewScanner(conn)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+
+ var req models.Request
+ if err := json.Unmarshal(line, &req); err != nil {
+ log.Warnf("handleConnection: Failed to unmarshal JSON: %v, line: %s", err, string(line))
+ models.RespondError(conn, 0, "invalid json")
+ continue
+ }
+
+ go RouteRequest(conn, req)
+ }
+}
+
+func getCapabilities() Capabilities {
+ caps := []string{"plugins"}
+
+ if networkManager != nil {
+ caps = append(caps, "network")
+ }
+
+ if loginctlManager != nil {
+ caps = append(caps, "loginctl")
+ }
+
+ if freedesktopManager != nil {
+ caps = append(caps, "freedesktop")
+ }
+
+ if waylandManager != nil {
+ caps = append(caps, "gamma")
+ }
+
+ if bluezManager != nil {
+ caps = append(caps, "bluetooth")
+ }
+
+ if cupsManager != nil {
+ caps = append(caps, "cups")
+ }
+
+ if dwlManager != nil {
+ caps = append(caps, "dwl")
+ }
+
+ if extWorkspaceManager != nil {
+ caps = append(caps, "extworkspace")
+ }
+
+ if brightnessManager != nil {
+ caps = append(caps, "brightness")
+ }
+
+ if wlrOutputManager != nil {
+ caps = append(caps, "wlroutput")
+ }
+
+ return Capabilities{Capabilities: caps}
+}
+
+func getServerInfo() ServerInfo {
+ caps := []string{"plugins"}
+
+ if networkManager != nil {
+ caps = append(caps, "network")
+ }
+
+ if loginctlManager != nil {
+ caps = append(caps, "loginctl")
+ }
+
+ if freedesktopManager != nil {
+ caps = append(caps, "freedesktop")
+ }
+
+ if waylandManager != nil {
+ caps = append(caps, "gamma")
+ }
+
+ if bluezManager != nil {
+ caps = append(caps, "bluetooth")
+ }
+
+ if cupsManager != nil {
+ caps = append(caps, "cups")
+ }
+
+ if dwlManager != nil {
+ caps = append(caps, "dwl")
+ }
+
+ if extWorkspaceManager != nil {
+ caps = append(caps, "extworkspace")
+ }
+
+ if brightnessManager != nil {
+ caps = append(caps, "brightness")
+ }
+
+ if wlrOutputManager != nil {
+ caps = append(caps, "wlroutput")
+ }
+
+ return ServerInfo{
+ APIVersion: APIVersion,
+ Capabilities: caps,
+ }
+}
+
+func notifyCapabilityChange() {
+ capabilityMutex.RLock()
+ defer capabilityMutex.RUnlock()
+
+ info := getServerInfo()
+ for _, ch := range capabilitySubscribers {
+ select {
+ case ch <- info:
+ default:
+ }
+ }
+}
+
+func handleSubscribe(conn net.Conn, req models.Request) {
+ clientID := fmt.Sprintf("meta-client-%p", conn)
+
+ var services []string
+ if servicesParam, ok := req.Params["services"].([]interface{}); ok {
+ for _, s := range servicesParam {
+ if str, ok := s.(string); ok {
+ services = append(services, str)
+ }
+ }
+ }
+
+ if len(services) == 0 {
+ services = []string{"all"}
+ }
+
+ subscribeAll := false
+ for _, s := range services {
+ if s == "all" {
+ subscribeAll = true
+ break
+ }
+ }
+
+ var wg sync.WaitGroup
+ eventChan := make(chan ServiceEvent, 256)
+ stopChan := make(chan struct{})
+
+ capChan := make(chan ServerInfo, 64)
+ capabilityMutex.Lock()
+ capabilitySubscribers[clientID+"-capabilities"] = capChan
+ capabilityMutex.Unlock()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ defer func() {
+ capabilityMutex.Lock()
+ delete(capabilitySubscribers, clientID+"-capabilities")
+ capabilityMutex.Unlock()
+ }()
+
+ for {
+ select {
+ case info, ok := <-capChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "server", Data: info}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+
+ shouldSubscribe := func(service string) bool {
+ if subscribeAll {
+ return true
+ }
+ for _, s := range services {
+ if s == service {
+ return true
+ }
+ }
+ return false
+ }
+
+ if shouldSubscribe("network") && networkManager != nil {
+ wg.Add(1)
+ netChan := networkManager.Subscribe(clientID + "-network")
+ go func() {
+ defer wg.Done()
+ defer networkManager.Unsubscribe(clientID + "-network")
+
+ initialState := networkManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "network", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-netChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "network", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("network.credentials") && networkManager != nil {
+ wg.Add(1)
+ credChan := networkManager.SubscribeCredentials(clientID + "-credentials")
+ go func() {
+ defer wg.Done()
+ defer networkManager.UnsubscribeCredentials(clientID + "-credentials")
+
+ for {
+ select {
+ case prompt, ok := <-credChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "network.credentials", Data: prompt}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("loginctl") && loginctlManager != nil {
+ wg.Add(1)
+ loginChan := loginctlManager.Subscribe(clientID + "-loginctl")
+ go func() {
+ defer wg.Done()
+ defer loginctlManager.Unsubscribe(clientID + "-loginctl")
+
+ initialState := loginctlManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "loginctl", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-loginChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "loginctl", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("freedesktop") && freedesktopManager != nil {
+ wg.Add(1)
+ freedesktopChan := freedesktopManager.Subscribe(clientID + "-freedesktop")
+ go func() {
+ defer wg.Done()
+ defer freedesktopManager.Unsubscribe(clientID + "-freedesktop")
+
+ initialState := freedesktopManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "freedesktop", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-freedesktopChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "freedesktop", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("gamma") && waylandManager != nil {
+ wg.Add(1)
+ waylandChan := waylandManager.Subscribe(clientID + "-gamma")
+ go func() {
+ defer wg.Done()
+ defer waylandManager.Unsubscribe(clientID + "-gamma")
+
+ initialState := waylandManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "gamma", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-waylandChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "gamma", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("bluetooth") && bluezManager != nil {
+ wg.Add(1)
+ bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
+ go func() {
+ defer wg.Done()
+ defer bluezManager.Unsubscribe(clientID + "-bluetooth")
+
+ initialState := bluezManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "bluetooth", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-bluezChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "bluetooth", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("bluetooth.pairing") && bluezManager != nil {
+ wg.Add(1)
+ pairingChan := bluezManager.SubscribePairing(clientID + "-pairing")
+ go func() {
+ defer wg.Done()
+ defer bluezManager.UnsubscribePairing(clientID + "-pairing")
+
+ for {
+ select {
+ case prompt, ok := <-pairingChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "bluetooth.pairing", Data: prompt}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("cups") {
+ cupsSubscribersMutex.Lock()
+ wasEmpty := len(cupsSubscribers) == 0
+ cupsSubscribers[clientID+"-cups"] = true
+ cupsSubscribersMutex.Unlock()
+
+ if wasEmpty {
+ if err := InitializeCupsManager(); err != nil {
+ log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }
+
+ if cupsManager != nil {
+ wg.Add(1)
+ cupsChan := cupsManager.Subscribe(clientID + "-cups")
+ go func() {
+ defer wg.Done()
+ defer func() {
+ cupsManager.Unsubscribe(clientID + "-cups")
+
+ cupsSubscribersMutex.Lock()
+ delete(cupsSubscribers, clientID+"-cups")
+ isEmpty := len(cupsSubscribers) == 0
+ cupsSubscribersMutex.Unlock()
+
+ if isEmpty {
+ log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
+ if cupsManager != nil {
+ cupsManager.Close()
+ cupsManager = nil
+ notifyCapabilityChange()
+ }
+ }
+ }()
+
+ initialState := cupsManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-cupsChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "cups", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+ }
+
+ if shouldSubscribe("dwl") && dwlManager != nil {
+ wg.Add(1)
+ dwlChan := dwlManager.Subscribe(clientID + "-dwl")
+ go func() {
+ defer wg.Done()
+ defer dwlManager.Unsubscribe(clientID + "-dwl")
+
+ initialState := dwlManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "dwl", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-dwlChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "dwl", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("extworkspace") && extWorkspaceManager != nil {
+ wg.Add(1)
+ extWorkspaceChan := extWorkspaceManager.Subscribe(clientID + "-extworkspace")
+ go func() {
+ defer wg.Done()
+ defer extWorkspaceManager.Unsubscribe(clientID + "-extworkspace")
+
+ initialState := extWorkspaceManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "extworkspace", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-extWorkspaceChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "extworkspace", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("brightness") && brightnessManager != nil {
+ wg.Add(2)
+ brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
+ brightnessUpdateChan := brightnessManager.SubscribeUpdates(clientID + "-brightness-updates")
+
+ go func() {
+ defer wg.Done()
+ defer brightnessManager.Unsubscribe(clientID + "-brightness-state")
+
+ initialState := brightnessManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "brightness", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-brightnessStateChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "brightness", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ defer brightnessManager.UnsubscribeUpdates(clientID + "-brightness-updates")
+
+ for {
+ select {
+ case update, ok := <-brightnessUpdateChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "brightness.update", Data: update}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ if shouldSubscribe("wlroutput") && wlrOutputManager != nil {
+ wg.Add(1)
+ wlrOutputChan := wlrOutputManager.Subscribe(clientID + "-wlroutput")
+ go func() {
+ defer wg.Done()
+ defer wlrOutputManager.Unsubscribe(clientID + "-wlroutput")
+
+ initialState := wlrOutputManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "wlroutput", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-wlrOutputChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "wlroutput", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
+ go func() {
+ wg.Wait()
+ close(eventChan)
+ }()
+
+ info := getServerInfo()
+ if err := json.NewEncoder(conn).Encode(models.Response[ServiceEvent]{
+ ID: req.ID,
+ Result: &ServiceEvent{Service: "server", Data: info},
+ }); err != nil {
+ close(stopChan)
+ return
+ }
+
+ for event := range eventChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[ServiceEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ close(stopChan)
+ return
+ }
+ }
+}
+
+func cleanupManagers() {
+ if networkManager != nil {
+ networkManager.Close()
+ }
+ if loginctlManager != nil {
+ loginctlManager.Close()
+ }
+ if freedesktopManager != nil {
+ freedesktopManager.Close()
+ }
+ if waylandManager != nil {
+ waylandManager.Close()
+ }
+ if bluezManager != nil {
+ bluezManager.Close()
+ }
+ if cupsManager != nil {
+ cupsManager.Close()
+ }
+ if dwlManager != nil {
+ dwlManager.Close()
+ }
+ if extWorkspaceManager != nil {
+ extWorkspaceManager.Close()
+ }
+ if brightnessManager != nil {
+ brightnessManager.Close()
+ }
+ if wlrOutputManager != nil {
+ wlrOutputManager.Close()
+ }
+ if wlContext != nil {
+ wlContext.Close()
+ }
+}
+
+func Start(printDocs bool) error {
+ cleanupStaleSockets()
+
+ socketPath := GetSocketPath()
+ os.Remove(socketPath)
+
+ listener, err := net.Listen("unix", socketPath)
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+ defer cleanupManagers()
+
+ log.Infof("DMS API Server listening on: %s", socketPath)
+ log.Infof("API Version: %d", APIVersion)
+ log.Info("Protocol: JSON over Unix socket")
+ log.Info("Request format: {\"id\": , \"method\": \"...\", \"params\": {...}}")
+ log.Info("Response format: {\"id\": , \"result\": {...}} or {\"id\": , \"error\": \"...\"}")
+ log.Info("")
+ if printDocs {
+ log.Info("Available methods:")
+ log.Info(" ping - Test connection")
+ log.Info(" getServerInfo - Get server info (API version and capabilities)")
+ log.Info(" subscribe - Subscribe to multiple services (params: services [default: all])")
+ log.Info("Plugins:")
+ log.Info(" plugins.list - List all plugins")
+ log.Info(" plugins.listInstalled - List installed plugins")
+ log.Info(" plugins.install - Install plugin (params: name)")
+ log.Info(" plugins.uninstall - Uninstall plugin (params: name)")
+ log.Info(" plugins.update - Update plugin (params: name)")
+ log.Info(" plugins.search - Search plugins (params: query, category?, compositor?, capability?)")
+ log.Info("Network:")
+ log.Info(" network.getState - Get current network state")
+ log.Info(" network.wifi.scan - Scan for WiFi networks")
+ log.Info(" network.wifi.networks - Get WiFi network list")
+ log.Info(" network.wifi.connect - Connect to WiFi (params: ssid, password?, username?)")
+ log.Info(" network.wifi.disconnect - Disconnect WiFi")
+ log.Info(" network.wifi.forget - Forget network (params: ssid)")
+ log.Info(" network.wifi.toggle - Toggle WiFi radio")
+ log.Info(" network.wifi.enable - Enable WiFi")
+ log.Info(" network.wifi.disable - Disable WiFi")
+ log.Info(" network.wifi.setAutoconnect - Set network autoconnect (params: ssid, autoconnect)")
+ log.Info(" network.ethernet.connect - Connect Ethernet")
+ log.Info(" network.ethernet.connect.config - Connect Ethernet to a specific configuration")
+ log.Info(" network.ethernet.disconnect - Disconnect Ethernet")
+ log.Info(" network.vpn.profiles - List VPN profiles")
+ log.Info(" network.vpn.active - List active VPN connections")
+ log.Info(" network.vpn.connect - Connect VPN (params: uuidOrName|name|uuid, singleActive?)")
+ log.Info(" network.vpn.disconnect - Disconnect VPN (params: uuidOrName|name|uuid)")
+ log.Info(" network.vpn.disconnectAll - Disconnect all VPNs")
+ log.Info(" network.vpn.clearCredentials - Clear saved VPN credentials (params: uuidOrName|name|uuid)")
+ log.Info(" network.preference.set - Set preference (params: preference [auto|wifi|ethernet])")
+ log.Info(" network.info - Get network info (params: ssid)")
+ log.Info(" network.credentials.submit - Submit credentials for prompt (params: token, secrets, save?)")
+ log.Info(" network.credentials.cancel - Cancel credential prompt (params: token)")
+ log.Info(" network.subscribe - Subscribe to network state changes (streaming)")
+ log.Info("Loginctl:")
+ log.Info(" loginctl.getState - Get current session state")
+ log.Info(" loginctl.lock - Lock session")
+ log.Info(" loginctl.unlock - Unlock session")
+ log.Info(" loginctl.activate - Activate session")
+ log.Info(" loginctl.setIdleHint - Set idle hint (params: idle)")
+ log.Info(" loginctl.setLockBeforeSuspend - Set lock before suspend (params: enabled)")
+ log.Info(" loginctl.setSleepInhibitorEnabled - Enable/disable sleep inhibitor (params: enabled)")
+ log.Info(" loginctl.lockerReady - Signal locker UI is ready (releases sleep inhibitor)")
+ log.Info(" loginctl.terminate - Terminate session")
+ log.Info(" loginctl.subscribe - Subscribe to session state changes (streaming)")
+ log.Info("Freedesktop:")
+ log.Info(" freedesktop.getState - Get accounts & settings state")
+ log.Info(" freedesktop.accounts.setIconFile - Set profile icon (params: path)")
+ log.Info(" freedesktop.accounts.setRealName - Set real name (params: name)")
+ log.Info(" freedesktop.accounts.setEmail - Set email (params: email)")
+ log.Info(" freedesktop.accounts.setLanguage - Set language (params: language)")
+ log.Info(" freedesktop.accounts.setLocation - Set location (params: location)")
+ log.Info(" freedesktop.accounts.getUserIconFile - Get user icon (params: username)")
+ log.Info(" freedesktop.settings.getColorScheme - Get color scheme")
+ log.Info(" freedesktop.settings.setIconTheme - Set icon theme (params: iconTheme)")
+ log.Info("Wayland:")
+ log.Info(" wayland.gamma.getState - Get current gamma control state")
+ log.Info(" wayland.gamma.setTemperature - Set temperature range (params: low, high)")
+ log.Info(" wayland.gamma.setLocation - Set location (params: latitude, longitude)")
+ log.Info(" wayland.gamma.setManualTimes - Set manual times (params: sunrise, sunset)")
+ log.Info(" wayland.gamma.setGamma - Set gamma value (params: gamma)")
+ log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)")
+ log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)")
+ log.Info("Bluetooth:")
+ log.Info(" bluetooth.getState - Get current bluetooth state")
+ log.Info(" bluetooth.startDiscovery - Start device discovery")
+ log.Info(" bluetooth.stopDiscovery - Stop device discovery")
+ log.Info(" bluetooth.setPowered - Set adapter power state (params: powered)")
+ log.Info(" bluetooth.pair - Pair with device (params: device)")
+ log.Info(" bluetooth.connect - Connect to device (params: device)")
+ log.Info(" bluetooth.disconnect - Disconnect from device (params: device)")
+ log.Info(" bluetooth.remove - Remove/unpair device (params: device)")
+ log.Info(" bluetooth.trust - Trust device (params: device)")
+ log.Info(" bluetooth.untrust - Untrust device (params: device)")
+ log.Info(" bluetooth.pairing.submit - Submit pairing response (params: token, secrets?, accept?)")
+ log.Info(" bluetooth.pairing.cancel - Cancel pairing prompt (params: token)")
+ log.Info(" bluetooth.subscribe - Subscribe to bluetooth state changes (streaming)")
+ log.Info("CUPS:")
+ log.Info(" cups.getPrinters - Get printers list")
+ log.Info(" cups.getJobs - Get non-completed jobs list (params: printerName)")
+ log.Info(" cups.pausePrinter - Pause printer (params: printerName)")
+ log.Info(" cups.resumePrinter - Resume printer (params: printerName)")
+ log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)")
+ log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)")
+ log.Info("DWL:")
+ log.Info(" dwl.getState - Get current dwl state (tags, windows, layouts)")
+ log.Info(" dwl.setTags - Set active tags (params: output, tagmask, toggleTagset)")
+ log.Info(" dwl.setClientTags - Set focused client tags (params: output, andTags, xorTags)")
+ log.Info(" dwl.setLayout - Set layout (params: output, index)")
+ log.Info(" dwl.subscribe - Subscribe to dwl state changes (streaming)")
+ log.Info("ExtWorkspace:")
+ log.Info(" extworkspace.getState - Get current workspace state (groups, workspaces)")
+ log.Info(" extworkspace.activateWorkspace - Activate workspace (params: groupID, workspaceID)")
+ log.Info(" extworkspace.deactivateWorkspace - Deactivate workspace (params: groupID, workspaceID)")
+ log.Info(" extworkspace.removeWorkspace - Remove workspace (params: groupID, workspaceID)")
+ log.Info(" extworkspace.createWorkspace - Create workspace (params: groupID, name)")
+ log.Info(" extworkspace.subscribe - Subscribe to workspace state changes (streaming)")
+ log.Info("Brightness:")
+ log.Info(" brightness.getState - Get current brightness state for all devices")
+ log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
+ log.Info(" brightness.increment - Increment device brightness (params: device, step?)")
+ log.Info(" brightness.decrement - Decrement device brightness (params: device, step?)")
+ log.Info(" brightness.rescan - Rescan for brightness devices (e.g., after plugging in monitor)")
+ log.Info(" brightness.subscribe - Subscribe to brightness state changes (streaming)")
+ log.Info(" Subscription events:")
+ log.Info(" - brightness : Full device list (on rescan, DDC discovery, device changes)")
+ log.Info(" - brightness.update: Single device update (on brightness change for efficiency)")
+ log.Info("WlrOutput:")
+ log.Info(" wlroutput.getState - Get current output configuration state")
+ log.Info(" wlroutput.applyConfiguration - Apply output configuration (params: heads)")
+ log.Info(" wlroutput.testConfiguration - Test output configuration without applying (params: heads)")
+ log.Info(" wlroutput.subscribe - Subscribe to output state changes (streaming)")
+ log.Info(" Head configuration params:")
+ log.Info(" - name : Output name (required)")
+ log.Info(" - enabled : Enable/disable output (required)")
+ log.Info(" - modeId : Mode ID from available modes (optional)")
+ log.Info(" - customMode : Custom mode {width, height, refresh} (optional)")
+ log.Info(" - position : Position {x, y} (optional)")
+ log.Info(" - transform : Transform value (optional)")
+ log.Info(" - scale : Scale value (optional)")
+ log.Info(" - adaptiveSync : Adaptive sync state (optional)")
+ log.Info("")
+ }
+ log.Info("Initializing managers...")
+ log.Info("")
+
+ go func() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ if err := InitializeNetworkManager(); err != nil {
+ log.Warnf("Network manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ return
+ }
+
+ for range ticker.C {
+ if networkManager != nil {
+ return
+ }
+ if err := InitializeNetworkManager(); err == nil {
+ log.Info("Network manager initialized")
+ notifyCapabilityChange()
+ return
+ }
+ }
+ }()
+
+ go func() {
+ if err := InitializeLoginctlManager(); err != nil {
+ log.Warnf("Loginctl manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ go func() {
+ if err := InitializeFreedeskManager(); err != nil {
+ log.Warnf("Freedesktop manager unavailable: %v", err)
+ } else if freedesktopManager != nil {
+ freedesktopManager.NotifySubscribers()
+ notifyCapabilityChange()
+ }
+ }()
+
+ if err := InitializeWaylandManager(); err != nil {
+ log.Warnf("Wayland manager unavailable: %v", err)
+ }
+
+ go func() {
+ if err := InitializeBluezManager(); err != nil {
+ log.Warnf("Bluez manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ if err := InitializeDwlManager(); err != nil {
+ log.Debugf("DWL manager unavailable: %v", err)
+ }
+
+ if err := InitializeExtWorkspaceManager(); err != nil {
+ log.Debugf("ExtWorkspace manager unavailable: %v", err)
+ }
+
+ if err := InitializeWlrOutputManager(); err != nil {
+ log.Debugf("WlrOutput manager unavailable: %v", err)
+ }
+
+ fatalErrChan := make(chan error, 1)
+ if wlrOutputManager != nil {
+ go func() {
+ select {
+ case err := <-wlrOutputManager.FatalError():
+ fatalErrChan <- fmt.Errorf("WlrOutput fatal error: %w", err)
+ }
+ }()
+ }
+
+ go func() {
+ if err := InitializeBrightnessManager(); err != nil {
+ log.Warnf("Brightness manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ }
+ }()
+
+ if wlContext != nil {
+ wlContext.Start()
+ log.Info("Wayland event dispatcher started")
+ }
+
+ log.Info("")
+ log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
+
+ listenerErrChan := make(chan error, 1)
+
+ go func() {
+ for {
+ conn, err := listener.Accept()
+ if err != nil {
+ listenerErrChan <- err
+ return
+ }
+ go handleConnection(conn)
+ }
+ }()
+
+ select {
+ case err := <-listenerErrChan:
+ return err
+ case err := <-fatalErrChan:
+ return err
+ }
+}
diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go
new file mode 100644
index 00000000..43c0af09
--- /dev/null
+++ b/backend/internal/server/server_test.go
@@ -0,0 +1,180 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/network"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetSocketDir(t *testing.T) {
+ tests := []struct {
+ name string
+ xdgRuntimeDir string
+ uid int
+ expectedSubstr string
+ }{
+ {
+ name: "uses XDG_RUNTIME_DIR when set",
+ xdgRuntimeDir: "/run/user/1000",
+ expectedSubstr: "/run/user/1000",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.xdgRuntimeDir != "" {
+ t.Setenv("XDG_RUNTIME_DIR", tt.xdgRuntimeDir)
+ }
+
+ result := getSocketDir()
+ assert.Contains(t, result, tt.expectedSubstr)
+ })
+ }
+}
+
+func TestGetSocketPath(t *testing.T) {
+ path := GetSocketPath()
+ assert.Contains(t, path, "danklinux-")
+ assert.Contains(t, path, ".sock")
+ assert.Contains(t, path, fmt.Sprintf("%d", os.Getpid()))
+}
+
+func TestGetCapabilities(t *testing.T) {
+ originalNetworkManager := networkManager
+ defer func() { networkManager = originalNetworkManager }()
+
+ t.Run("capabilities without network manager", func(t *testing.T) {
+ networkManager = nil
+ caps := getCapabilities()
+ assert.Contains(t, caps.Capabilities, "plugins")
+ assert.NotContains(t, caps.Capabilities, "network")
+ })
+
+ t.Run("capabilities with network manager", func(t *testing.T) {
+ networkManager = &network.Manager{}
+ caps := getCapabilities()
+ assert.Contains(t, caps.Capabilities, "plugins")
+ assert.Contains(t, caps.Capabilities, "network")
+ })
+}
+
+type mockConn struct {
+ net.Conn
+ written []byte
+}
+
+func (m *mockConn) Write(b []byte) (n int, err error) {
+ m.written = append(m.written, b...)
+ return len(b), nil
+}
+
+func (m *mockConn) Close() error {
+ return nil
+}
+
+func TestRespondError(t *testing.T) {
+ conn := &mockConn{}
+ models.RespondError(conn, 123, "test error")
+
+ var resp models.Response[any]
+ err := json.Unmarshal(conn.written, &resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Equal(t, "test error", resp.Error)
+ assert.Nil(t, resp.Result)
+}
+
+func TestRespond(t *testing.T) {
+ conn := &mockConn{}
+ result := map[string]string{"foo": "bar"}
+ models.Respond(conn, 123, result)
+
+ var resp models.Response[map[string]string]
+ err := json.Unmarshal(conn.written, &resp)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, resp.ID)
+ assert.Empty(t, resp.Error)
+ require.NotNil(t, resp.Result)
+ assert.Equal(t, "bar", (*resp.Result)["foo"])
+}
+
+func TestRequest_JSON(t *testing.T) {
+ jsonStr := `{"id":123,"method":"test.method","params":{"key":"value"}}`
+ var req models.Request
+ err := json.Unmarshal([]byte(jsonStr), &req)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, req.ID)
+ assert.Equal(t, "test.method", req.Method)
+ assert.Equal(t, "value", req.Params["key"])
+}
+
+func TestResponse_JSON(t *testing.T) {
+ t.Run("success response", func(t *testing.T) {
+ result := "success"
+ resp := models.Response[string]{
+ ID: 123,
+ Result: &result,
+ }
+
+ data, err := json.Marshal(resp)
+ require.NoError(t, err)
+
+ var decoded models.Response[string]
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, decoded.ID)
+ assert.Equal(t, "success", *decoded.Result)
+ assert.Empty(t, decoded.Error)
+ })
+
+ t.Run("error response", func(t *testing.T) {
+ resp := models.Response[any]{
+ ID: 123,
+ Error: "test error",
+ }
+
+ data, err := json.Marshal(resp)
+ require.NoError(t, err)
+
+ var decoded models.Response[any]
+ err = json.Unmarshal(data, &decoded)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123, decoded.ID)
+ assert.Equal(t, "test error", decoded.Error)
+ assert.Nil(t, decoded.Result)
+ })
+}
+
+func TestCleanupStaleSockets(t *testing.T) {
+ tempDir := t.TempDir()
+ t.Setenv("XDG_RUNTIME_DIR", tempDir)
+
+ staleSocket := filepath.Join(tempDir, "danklinux-999999.sock")
+ err := os.WriteFile(staleSocket, []byte{}, 0600)
+ require.NoError(t, err)
+
+ activeSocket := filepath.Join(tempDir, fmt.Sprintf("danklinux-%d.sock", os.Getpid()))
+ err = os.WriteFile(activeSocket, []byte{}, 0600)
+ require.NoError(t, err)
+
+ cleanupStaleSockets()
+
+ _, err = os.Stat(staleSocket)
+ assert.True(t, os.IsNotExist(err))
+
+ _, err = os.Stat(activeSocket)
+ assert.NoError(t, err)
+}
diff --git a/backend/internal/server/wayland/gamma.go b/backend/internal/server/wayland/gamma.go
new file mode 100644
index 00000000..c4e9a846
--- /dev/null
+++ b/backend/internal/server/wayland/gamma.go
@@ -0,0 +1,88 @@
+package wayland
+
+import (
+ "math"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/utils"
+)
+
+type GammaRamp struct {
+ Red []uint16
+ Green []uint16
+ Blue []uint16
+}
+
+func GenerateGammaRamp(size uint32, temp int, gamma float64) GammaRamp {
+ ramp := GammaRamp{
+ Red: make([]uint16, size),
+ Green: make([]uint16, size),
+ Blue: make([]uint16, size),
+ }
+
+ for i := uint32(0); i < size; i++ {
+ val := float64(i) / float64(size-1)
+
+ valGamma := math.Pow(val, 1.0/gamma)
+
+ r, g, b := temperatureToRGB(temp)
+
+ ramp.Red[i] = uint16(utils.Clamp(valGamma*r*65535.0, 0, 65535))
+ ramp.Green[i] = uint16(utils.Clamp(valGamma*g*65535.0, 0, 65535))
+ ramp.Blue[i] = uint16(utils.Clamp(valGamma*b*65535.0, 0, 65535))
+ }
+
+ return ramp
+}
+
+func GenerateIdentityRamp(size uint32) GammaRamp {
+ ramp := GammaRamp{
+ Red: make([]uint16, size),
+ Green: make([]uint16, size),
+ Blue: make([]uint16, size),
+ }
+
+ for i := uint32(0); i < size; i++ {
+ val := uint16((float64(i) / float64(size-1)) * 65535.0)
+ ramp.Red[i] = val
+ ramp.Green[i] = val
+ ramp.Blue[i] = val
+ }
+
+ return ramp
+}
+
+func temperatureToRGB(temp int) (float64, float64, float64) {
+ tempK := float64(temp) / 100.0
+
+ var r, g, b float64
+
+ if tempK <= 66 {
+ r = 1.0
+ } else {
+ r = tempK - 60
+ r = 329.698727446 * math.Pow(r, -0.1332047592)
+ r = utils.Clamp(r, 0, 255) / 255.0
+ }
+
+ if tempK <= 66 {
+ g = tempK
+ g = 99.4708025861*math.Log(g) - 161.1195681661
+ g = utils.Clamp(g, 0, 255) / 255.0
+ } else {
+ g = tempK - 60
+ g = 288.1221695283 * math.Pow(g, -0.0755148492)
+ g = utils.Clamp(g, 0, 255) / 255.0
+ }
+
+ if tempK >= 66 {
+ b = 1.0
+ } else if tempK <= 19 {
+ b = 0.0
+ } else {
+ b = tempK - 10
+ b = 138.5177312231*math.Log(b) - 305.0447927307
+ b = utils.Clamp(b, 0, 255) / 255.0
+ }
+
+ return r, g, b
+}
diff --git a/backend/internal/server/wayland/gamma_test.go b/backend/internal/server/wayland/gamma_test.go
new file mode 100644
index 00000000..14add9e1
--- /dev/null
+++ b/backend/internal/server/wayland/gamma_test.go
@@ -0,0 +1,120 @@
+package wayland
+
+import (
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/utils"
+)
+
+func TestGenerateGammaRamp(t *testing.T) {
+ tests := []struct {
+ name string
+ size uint32
+ temp int
+ gamma float64
+ }{
+ {"small_warm", 16, 6500, 1.0},
+ {"small_cool", 16, 4000, 1.0},
+ {"large_warm", 256, 6500, 1.0},
+ {"large_cool", 256, 4000, 1.0},
+ {"custom_gamma", 64, 5500, 1.2},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ramp := GenerateGammaRamp(tt.size, tt.temp, tt.gamma)
+
+ if len(ramp.Red) != int(tt.size) {
+ t.Errorf("expected %d red values, got %d", tt.size, len(ramp.Red))
+ }
+ if len(ramp.Green) != int(tt.size) {
+ t.Errorf("expected %d green values, got %d", tt.size, len(ramp.Green))
+ }
+ if len(ramp.Blue) != int(tt.size) {
+ t.Errorf("expected %d blue values, got %d", tt.size, len(ramp.Blue))
+ }
+
+ if ramp.Red[0] != 0 || ramp.Green[0] != 0 || ramp.Blue[0] != 0 {
+ t.Errorf("first values should be 0, got R:%d G:%d B:%d",
+ ramp.Red[0], ramp.Green[0], ramp.Blue[0])
+ }
+
+ lastIdx := tt.size - 1
+ if ramp.Red[lastIdx] == 0 || ramp.Green[lastIdx] == 0 || ramp.Blue[lastIdx] == 0 {
+ t.Errorf("last values should be non-zero, got R:%d G:%d B:%d",
+ ramp.Red[lastIdx], ramp.Green[lastIdx], ramp.Blue[lastIdx])
+ }
+
+ for i := uint32(1); i < tt.size; i++ {
+ if ramp.Red[i] < ramp.Red[i-1] {
+ t.Errorf("red ramp not monotonic at index %d", i)
+ }
+ }
+ })
+ }
+}
+
+func TestTemperatureToRGB(t *testing.T) {
+ tests := []struct {
+ name string
+ temp int
+ }{
+ {"very_warm", 6500},
+ {"neutral", 5500},
+ {"cool", 4000},
+ {"very_cool", 3000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r, g, b := temperatureToRGB(tt.temp)
+
+ if r < 0 || r > 1 {
+ t.Errorf("red out of range: %f", r)
+ }
+ if g < 0 || g > 1 {
+ t.Errorf("green out of range: %f", g)
+ }
+ if b < 0 || b > 1 {
+ t.Errorf("blue out of range: %f", b)
+ }
+ })
+ }
+}
+
+func TestTemperatureProgression(t *testing.T) {
+ temps := []int{3000, 4000, 5000, 6000, 6500}
+
+ var prevBlue float64
+ for i, temp := range temps {
+ _, _, b := temperatureToRGB(temp)
+ if i > 0 && b < prevBlue {
+ t.Errorf("blue should increase with temperature, %d->%d: %f->%f",
+ temps[i-1], temp, prevBlue, b)
+ }
+ prevBlue = b
+ }
+}
+
+func TestClamp(t *testing.T) {
+ tests := []struct {
+ val float64
+ min float64
+ max float64
+ expected float64
+ }{
+ {5, 0, 10, 5},
+ {-5, 0, 10, 0},
+ {15, 0, 10, 10},
+ {0, 0, 10, 0},
+ {10, 0, 10, 10},
+ }
+
+ for _, tt := range tests {
+ result := utils.Clamp(tt.val, tt.min, tt.max)
+ if result != tt.expected {
+ t.Errorf("clamp(%f, %f, %f) = %f, want %f",
+ tt.val, tt.min, tt.max, result, tt.expected)
+ }
+ }
+}
diff --git a/backend/internal/server/wayland/geolocation.go b/backend/internal/server/wayland/geolocation.go
new file mode 100644
index 00000000..b7f10707
--- /dev/null
+++ b/backend/internal/server/wayland/geolocation.go
@@ -0,0 +1,50 @@
+package wayland
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+)
+
+type ipAPIResponse struct {
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ City string `json:"city"`
+}
+
+func FetchIPLocation() (*float64, *float64, error) {
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ resp, err := client.Get("http://ip-api.com/json/")
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to fetch IP location: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, nil, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var data ipAPIResponse
+ if err := json.Unmarshal(body, &data); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if data.Lat == 0 && data.Lon == 0 {
+ return nil, nil, fmt.Errorf("missing location data in response")
+ }
+
+ log.Infof("Fetched IP-based location: %s (%.4f, %.4f)", data.City, data.Lat, data.Lon)
+ return &data.Lat, &data.Lon, nil
+}
diff --git a/backend/internal/server/wayland/handlers.go b/backend/internal/server/wayland/handlers.go
new file mode 100644
index 00000000..4db0ea0a
--- /dev/null
+++ b/backend/internal/server/wayland/handlers.go
@@ -0,0 +1,205 @@
+package wayland
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "wayland manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "wayland.gamma.getState":
+ handleGetState(conn, req, manager)
+ case "wayland.gamma.setTemperature":
+ handleSetTemperature(conn, req, manager)
+ case "wayland.gamma.setLocation":
+ handleSetLocation(conn, req, manager)
+ case "wayland.gamma.setManualTimes":
+ handleSetManualTimes(conn, req, manager)
+ case "wayland.gamma.setUseIPLocation":
+ handleSetUseIPLocation(conn, req, manager)
+ case "wayland.gamma.setGamma":
+ handleSetGamma(conn, req, manager)
+ case "wayland.gamma.setEnabled":
+ handleSetEnabled(conn, req, manager)
+ case "wayland.gamma.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleSetTemperature(conn net.Conn, req Request, manager *Manager) {
+ var lowTemp, highTemp int
+
+ if temp, ok := req.Params["temp"].(float64); ok {
+ lowTemp = int(temp)
+ highTemp = int(temp)
+ } else {
+ low, okLow := req.Params["low"].(float64)
+ high, okHigh := req.Params["high"].(float64)
+
+ if !okLow || !okHigh {
+ models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')")
+ return
+ }
+
+ lowTemp = int(low)
+ highTemp = int(high)
+ }
+
+ if err := manager.SetTemperature(lowTemp, highTemp); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "temperature set"})
+}
+
+func handleSetLocation(conn net.Conn, req Request, manager *Manager) {
+ lat, ok := req.Params["latitude"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'latitude' parameter")
+ return
+ }
+
+ lon, ok := req.Params["longitude"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'longitude' parameter")
+ return
+ }
+
+ if err := manager.SetLocation(lat, lon); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "location set"})
+}
+
+func handleSetManualTimes(conn net.Conn, req Request, manager *Manager) {
+ sunriseParam := req.Params["sunrise"]
+ sunsetParam := req.Params["sunset"]
+
+ if sunriseParam == nil || sunsetParam == nil {
+ manager.ClearManualTimes()
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "manual times cleared"})
+ return
+ }
+
+ sunriseStr, ok := sunriseParam.(string)
+ if !ok || sunriseStr == "" {
+ manager.ClearManualTimes()
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "manual times cleared"})
+ return
+ }
+
+ sunsetStr, ok := sunsetParam.(string)
+ if !ok || sunsetStr == "" {
+ manager.ClearManualTimes()
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "manual times cleared"})
+ return
+ }
+
+ sunrise, err := time.Parse("15:04", sunriseStr)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid sunrise format (use HH:MM)")
+ return
+ }
+
+ sunset, err := time.Parse("15:04", sunsetStr)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid sunset format (use HH:MM)")
+ return
+ }
+
+ if err := manager.SetManualTimes(sunrise, sunset); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "manual times set"})
+}
+
+func handleSetUseIPLocation(conn net.Conn, req Request, manager *Manager) {
+ use, ok := req.Params["use"].(bool)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'use' parameter")
+ return
+ }
+
+ manager.SetUseIPLocation(use)
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "IP location preference set"})
+}
+
+func handleSetGamma(conn net.Conn, req Request, manager *Manager) {
+ gamma, ok := req.Params["gamma"].(float64)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'gamma' parameter")
+ return
+ }
+
+ if err := manager.SetGamma(gamma); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "gamma set"})
+}
+
+func handleSetEnabled(conn net.Conn, req Request, manager *Manager) {
+ enabled, ok := req.Params["enabled"].(bool)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'enabled' parameter")
+ return
+ }
+
+ manager.SetEnabled(enabled)
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "enabled state set"})
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/backend/internal/server/wayland/manager.go b/backend/internal/server/wayland/manager.go
new file mode 100644
index 00000000..0e0d1777
--- /dev/null
+++ b/backend/internal/server/wayland/manager.go
@@ -0,0 +1,1367 @@
+package wayland
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "os"
+ "syscall"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+ "golang.org/x/sys/unix"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/proto/wlr_gamma_control"
+)
+
+func NewManager(display *wlclient.Display, config Config) (*Manager, error) {
+ if err := config.Validate(); err != nil {
+ return nil, err
+ }
+
+ m := &Manager{
+ config: config,
+ display: display,
+ outputs: make(map[uint32]*outputState),
+ cmdq: make(chan cmd, 128),
+ stopChan: make(chan struct{}),
+ updateTrigger: make(chan struct{}, 1),
+ subscribers: make(map[string]chan State),
+ dirty: make(chan struct{}, 1),
+ dbusSignal: make(chan *dbus.Signal, 16),
+ transitionChan: make(chan int, 1),
+ }
+
+ if err := m.setupRegistry(); err != nil {
+ return nil, err
+ }
+
+ // Setup D-Bus monitoring for suspend/resume events
+ if err := m.setupDBusMonitor(); err != nil {
+ log.Warnf("Failed to setup D-Bus monitoring for suspend/resume: %v", err)
+ // Don't fail initialization if D-Bus setup fails, just continue without it
+ }
+
+ // Initialize currentTemp and targetTemp before starting any goroutines
+ now := time.Now()
+ initial := m.calculateTemperature(now)
+ m.transitionMutex.Lock()
+ m.currentTemp = initial
+ m.targetTemp = initial
+ m.transitionMutex.Unlock()
+
+ m.alive = true
+ m.updateState()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.wg.Add(1)
+ go m.updateLoop()
+
+ if m.dbusConn != nil {
+ m.wg.Add(1)
+ go m.dbusMonitor()
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ m.wg.Add(1)
+ go m.transitionWorker()
+
+ if config.Enabled {
+ m.post(func() {
+ log.Info("Gamma control enabled at startup, initializing controls")
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ if err := func() error {
+ var outputs []*wlclient.Output = m.availableOutputs
+ return m.setupOutputControls(outputs, gammaMgr)
+ }(); err != nil {
+ log.Errorf("Failed to initialize gamma controls: %v", err)
+ } else {
+ m.controlsInitialized = true
+ }
+ })
+ }
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("Actor command queue full, dropping command")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ c.fn()
+ }
+ }
+}
+
+func (m *Manager) allOutputsReady() bool {
+ m.outputsMutex.RLock()
+ defer m.outputsMutex.RUnlock()
+ if len(m.outputs) == 0 {
+ return false
+ }
+ for _, o := range m.outputs {
+ if o.rampSize == 0 || o.failed {
+ return false
+ }
+ }
+ return true
+}
+
+func (m *Manager) setupDBusMonitor() error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("failed to connect to system bus: %w", err)
+ }
+
+ // Subscribe to PrepareForSleep signal
+ matchRule := "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep',path='/org/freedesktop/login1'"
+ if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
+ conn.Close()
+ return fmt.Errorf("failed to add match rule: %w", err)
+ }
+
+ conn.Signal(m.dbusSignal)
+ m.dbusConn = conn
+
+ log.Info("D-Bus monitoring for suspend/resume events enabled")
+ return nil
+}
+
+func (m *Manager) setupRegistry() error {
+ log.Info("setupRegistry: starting registry setup")
+ ctx := m.display.Context()
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ outputs := make([]*wlclient.Output, 0)
+ outputRegNames := make(map[uint32]uint32)
+ outputNames := make(map[uint32]string)
+ var gammaMgr *wlr_gamma_control.ZwlrGammaControlManagerV1
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ switch e.Interface {
+ case wlr_gamma_control.ZwlrGammaControlManagerV1InterfaceName:
+ log.Infof("setupRegistry: found %s", wlr_gamma_control.ZwlrGammaControlManagerV1InterfaceName)
+ manager := wlr_gamma_control.NewZwlrGammaControlManagerV1(ctx)
+ version := e.Version
+ if version > 1 {
+ version = 1
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ gammaMgr = manager
+ log.Info("setupRegistry: gamma control manager bound successfully")
+ } else {
+ log.Errorf("setupRegistry: failed to bind gamma control: %v", err)
+ }
+ case "wl_output":
+ log.Debugf("Global event: found wl_output (name=%d)", e.Name)
+ output := wlclient.NewOutput(ctx)
+ version := e.Version
+ if version > 4 {
+ version = 4
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
+ outputID := output.ID()
+ log.Infof("Bound wl_output id=%d registry_name=%d", outputID, e.Name)
+
+ output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
+ log.Infof("Output %d name: %s", outputID, ev.Name)
+ outputNames[outputID] = ev.Name
+ isVirtual := len(ev.Name) >= 9 && ev.Name[:9] == "HEADLESS-"
+ if isVirtual {
+ log.Infof("Output %d identified as virtual", outputID)
+ }
+ })
+
+ if gammaMgr != nil {
+ outputs = append(outputs, output)
+ outputRegNames[outputID] = e.Name
+ }
+
+ m.outputsMutex.Lock()
+ if m.outputRegNames != nil {
+ m.outputRegNames[outputID] = e.Name
+ }
+ m.outputsMutex.Unlock()
+
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ if enabled && m.controlsInitialized {
+ m.post(func() {
+ log.Infof("New output %d added, creating gamma control", outputID)
+ if err := m.addOutputControl(output); err != nil {
+ log.Errorf("Failed to add gamma control for new output %d: %v", outputID, err)
+ }
+ })
+ } else if enabled && !m.controlsInitialized {
+ m.post(func() {
+ log.Infof("Output %d added after all were removed, creating gamma control", outputID)
+ if err := m.addOutputControl(output); err != nil {
+ log.Errorf("Failed to add gamma control for output %d: %v", outputID, err)
+ } else {
+ m.controlsInitialized = true
+ }
+ })
+ }
+ } else {
+ log.Errorf("Failed to bind wl_output: %v", err)
+ }
+ }
+ })
+
+ registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
+ m.post(func() {
+ m.outputsMutex.Lock()
+ defer m.outputsMutex.Unlock()
+
+ for id, out := range m.outputs {
+ if out.registryName == e.Name {
+ log.Infof("Output %d (registry name %d) removed, destroying gamma control", id, e.Name)
+ if out.gammaControl != nil {
+ control := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
+ control.Destroy()
+ }
+ delete(m.outputs, id)
+
+ if len(m.outputs) == 0 {
+ m.controlsInitialized = false
+ log.Info("All outputs removed, controls no longer initialized")
+ }
+ return
+ }
+ }
+ })
+ })
+
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("first roundtrip failed: %w", err)
+ }
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("second roundtrip failed: %w", err)
+ }
+
+ log.Infof("setupRegistry: discovered gamma_manager=%v, outputs=%d", gammaMgr != nil, len(outputs))
+
+ if gammaMgr == nil {
+ log.Error("setupRegistry: gamma control manager not found in registry")
+ return errdefs.ErrNoGammaControl
+ }
+
+ if len(outputs) == 0 {
+ log.Error("setupRegistry: no wl_output objects found")
+ return fmt.Errorf("no outputs available")
+ }
+
+ physicalOutputs := make([]*wlclient.Output, 0)
+ for _, output := range outputs {
+ outputID := output.ID()
+ name := outputNames[outputID]
+ if name != "" && (len(name) >= 9 && name[:9] == "HEADLESS-") {
+ log.Infof("Skipping virtual output %d (name=%s) for gamma control", outputID, name)
+ continue
+ }
+ physicalOutputs = append(physicalOutputs, output)
+ }
+
+ log.Infof("setupRegistry: filtered %d physical outputs from %d total outputs", len(physicalOutputs), len(outputs))
+
+ m.gammaControl = gammaMgr
+ m.availableOutputs = physicalOutputs
+ m.outputRegNames = outputRegNames
+
+ log.Info("setupRegistry: completed successfully (gamma controls will be initialized when enabled)")
+ return nil
+}
+
+func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_gamma_control.ZwlrGammaControlManagerV1) error {
+ log.Infof("setupOutputControls: creating gamma controls for %d outputs", len(outputs))
+
+ for _, output := range outputs {
+ control, err := manager.GetGammaControl(output)
+ if err != nil {
+ log.Warnf("Failed to get gamma control for output %d: %v", output.ID(), err)
+ continue
+ }
+
+ outState := &outputState{
+ id: output.ID(),
+ registryName: m.outputRegNames[output.ID()],
+ output: output,
+ gammaControl: control,
+ isVirtual: false,
+ }
+
+ func(state *outputState) {
+ control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
+ m.outputsMutex.Lock()
+ if outState, exists := m.outputs[state.id]; exists {
+ outState.rampSize = e.Size
+ outState.failed = false
+ outState.retryCount = 0
+ log.Infof("Output %d gamma_size=%d", state.id, e.Size)
+ }
+ m.outputsMutex.Unlock()
+
+ m.transitionMutex.RLock()
+ currentTemp := m.currentTemp
+ m.transitionMutex.RUnlock()
+
+ m.post(func() {
+ m.applyNowOnActor(currentTemp)
+ })
+ })
+
+ control.SetFailedHandler(func(e wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
+ m.outputsMutex.Lock()
+ if outState, exists := m.outputs[state.id]; exists {
+ outState.failed = true
+ outState.rampSize = 0
+ outState.retryCount++
+ outState.lastFailTime = time.Now()
+
+ retryCount := outState.retryCount
+ if retryCount == 1 || retryCount%5 == 0 {
+ log.Errorf("Gamma control failed for output %d (attempt %d)", state.id, retryCount)
+ }
+
+ backoff := time.Duration(300<= 9 && ev.Name[:9] == "HEADLESS-" {
+ log.Infof("Detected virtual output %d (name=%s), marking for gamma control skip", outputID, ev.Name)
+ outState.isVirtual = true
+ outState.failed = true
+ }
+ }
+ m.outputsMutex.Unlock()
+ })
+
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+
+ control, err := gammaMgr.GetGammaControl(output)
+ if err != nil {
+ return fmt.Errorf("failed to get gamma control: %w", err)
+ }
+
+ outState := &outputState{
+ id: outputID,
+ name: outputName,
+ registryName: m.outputRegNames[outputID],
+ output: output,
+ gammaControl: control,
+ isVirtual: false,
+ }
+
+ control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
+ m.outputsMutex.Lock()
+ if out, exists := m.outputs[outState.id]; exists {
+ out.rampSize = e.Size
+ out.failed = false
+ out.retryCount = 0
+ log.Infof("Output %d gamma_size=%d", outState.id, e.Size)
+ }
+ m.outputsMutex.Unlock()
+
+ m.transitionMutex.RLock()
+ currentTemp := m.currentTemp
+ m.transitionMutex.RUnlock()
+
+ m.post(func() {
+ m.applyNowOnActor(currentTemp)
+ })
+ })
+
+ control.SetFailedHandler(func(e wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
+ m.outputsMutex.Lock()
+ if out, exists := m.outputs[outState.id]; exists {
+ out.failed = true
+ out.rampSize = 0
+ out.retryCount++
+ out.lastFailTime = time.Now()
+
+ retryCount := out.retryCount
+ if retryCount == 1 || retryCount%5 == 0 {
+ log.Errorf("Gamma control failed for output %d (attempt %d)", outState.id, retryCount)
+ }
+
+ backoff := time.Duration(300< %dK over %v", currentTemp, targetTemp, dur)
+
+ for i := 0; i <= steps; i++ {
+ select {
+ case newTarget := <-m.transitionChan:
+ m.transitionMutex.Lock()
+ m.targetTemp = newTarget
+ m.transitionMutex.Unlock()
+ log.Debugf("Transition %dK -> %dK aborted (newer transition started)", currentTemp, targetTemp)
+ break
+ default:
+ }
+
+ m.transitionMutex.RLock()
+ if m.targetTemp != targetTemp {
+ m.transitionMutex.RUnlock()
+ break
+ }
+ m.transitionMutex.RUnlock()
+
+ progress := float64(i) / float64(steps)
+ temp := currentTemp + int(float64(targetTemp-currentTemp)*progress)
+
+ m.post(func() { m.applyNowOnActor(temp) })
+
+ if i < steps {
+ time.Sleep(stepDur)
+ }
+ }
+
+ m.transitionMutex.RLock()
+ finalTarget := m.targetTemp
+ m.transitionMutex.RUnlock()
+
+ if finalTarget == targetTemp {
+ log.Debugf("Transition complete: now at %dK", targetTemp)
+
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ identityTemp := m.config.HighTemp
+ m.configMutex.RUnlock()
+
+ if !enabled && targetTemp == identityTemp && m.controlsInitialized {
+ m.post(func() {
+ log.Info("Destroying gamma controls after transition to identity")
+ m.outputsMutex.Lock()
+ for id, out := range m.outputs {
+ if out.gammaControl != nil {
+ control := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
+ control.Destroy()
+ log.Debugf("Destroyed gamma control for output %d", id)
+ }
+ }
+ m.outputs = make(map[uint32]*outputState)
+ m.controlsInitialized = false
+ m.outputsMutex.Unlock()
+
+ m.transitionMutex.Lock()
+ m.currentTemp = identityTemp
+ m.targetTemp = identityTemp
+ m.transitionMutex.Unlock()
+
+ if _, err := m.display.Sync(); err != nil {
+ log.Warnf("Failed to sync Wayland display after destroying controls: %v", err)
+ }
+
+ log.Info("All gamma controls destroyed")
+ })
+ }
+ }
+ }
+ }
+}
+
+func (m *Manager) recreateOutputControl(out *outputState) error {
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ if !enabled || !m.controlsInitialized {
+ return nil
+ }
+
+ m.outputsMutex.RLock()
+ _, exists := m.outputs[out.id]
+ m.outputsMutex.RUnlock()
+
+ if !exists {
+ return nil
+ }
+
+ if out.isVirtual {
+ return nil
+ }
+
+ const maxRetries = 10
+ if out.retryCount >= maxRetries {
+ return nil
+ }
+
+ gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ if !ok || gammaMgr == nil {
+ return fmt.Errorf("gamma control manager not available")
+ }
+ control, err := gammaMgr.GetGammaControl(out.output)
+ if err != nil {
+ return fmt.Errorf("get gamma control: %w", err)
+ }
+
+ state := out
+ control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
+ m.outputsMutex.Lock()
+ if outState, exists := m.outputs[state.id]; exists {
+ outState.rampSize = e.Size
+ outState.failed = false
+ outState.retryCount = 0
+ log.Infof("Output %d gamma_size=%d (recreated)", state.id, e.Size)
+ }
+ m.outputsMutex.Unlock()
+
+ m.transitionMutex.RLock()
+ currentTemp := m.currentTemp
+ m.transitionMutex.RUnlock()
+
+ m.post(func() {
+ m.applyNowOnActor(currentTemp)
+ })
+ })
+
+ control.SetFailedHandler(func(e wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
+ m.outputsMutex.Lock()
+ if outState, exists := m.outputs[state.id]; exists {
+ outState.failed = true
+ outState.rampSize = 0
+ outState.retryCount++
+ outState.lastFailTime = time.Now()
+
+ retryCount := outState.retryCount
+ if retryCount == 1 || retryCount%5 == 0 {
+ log.Errorf("Gamma control failed for output %d (attempt %d)", state.id, retryCount)
+ }
+
+ backoff := time.Duration(300< 1 {
+ return SunTimes{
+ Sunrise: time.Date(year, month, day, 0, 0, 0, 0, time.UTC).In(loc),
+ Sunset: time.Date(year, month, day, 0, 0, 0, 0, time.UTC).In(loc),
+ }
+ }
+ if cosHourAngle < -1 {
+ return SunTimes{
+ Sunrise: time.Date(year, month, day, 0, 0, 0, 0, time.UTC).In(loc),
+ Sunset: time.Date(year, month, day, 23, 59, 59, 0, time.UTC).In(loc),
+ }
+ }
+
+ hourAngle := math.Acos(cosHourAngle) * radToDeg
+
+ sunriseTime := solarNoon - hourAngle/15.0 - lon/15.0 - eqTime/60.0
+ sunsetTime := solarNoon + hourAngle/15.0 - lon/15.0 - eqTime/60.0
+
+ sunrise := timeOfDayToTime(sunriseTime, year, month, day, time.UTC).In(loc)
+ sunset := timeOfDayToTime(sunsetTime, year, month, day, time.UTC).In(loc)
+
+ return SunTimes{
+ Sunrise: sunrise,
+ Sunset: sunset,
+ }
+}
+
+func timeOfDayToTime(hours float64, year int, month time.Month, day int, loc *time.Location) time.Time {
+ h := int(hours)
+ m := int((hours - float64(h)) * 60)
+ s := int(((hours-float64(h))*60 - float64(m)) * 60)
+
+ if h < 0 {
+ h += 24
+ day--
+ }
+ if h >= 24 {
+ h -= 24
+ day++
+ }
+
+ return time.Date(year, month, day, h, m, s, 0, loc)
+}
diff --git a/backend/internal/server/wayland/suncalc_test.go b/backend/internal/server/wayland/suncalc_test.go
new file mode 100644
index 00000000..0aaa535a
--- /dev/null
+++ b/backend/internal/server/wayland/suncalc_test.go
@@ -0,0 +1,378 @@
+package wayland
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+func calculateTemperature(config Config, now time.Time) int {
+ if !config.Enabled {
+ return config.HighTemp
+ }
+
+ var sunrise, sunset time.Time
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Date()
+ loc := now.Location()
+
+ sunrise = time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ sunset = time.Date(year, month, day,
+ config.ManualSunset.Hour(),
+ config.ManualSunset.Minute(),
+ config.ManualSunset.Second(), 0, loc)
+
+ if sunset.Before(sunrise) {
+ sunset = sunset.Add(24 * time.Hour)
+ }
+ } else if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return config.HighTemp
+ }
+ times := CalculateSunTimes(*lat, *lon, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else if config.Latitude != nil && config.Longitude != nil {
+ times := CalculateSunTimes(*config.Latitude, *config.Longitude, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else {
+ return config.HighTemp
+ }
+
+ if now.Before(sunrise) || now.After(sunset) {
+ return config.LowTemp
+ }
+ return config.HighTemp
+}
+
+func calculateNextTransition(config Config, now time.Time) time.Time {
+ if !config.Enabled {
+ return now.Add(24 * time.Hour)
+ }
+
+ var sunrise, sunset time.Time
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Date()
+ loc := now.Location()
+
+ sunrise = time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ sunset = time.Date(year, month, day,
+ config.ManualSunset.Hour(),
+ config.ManualSunset.Minute(),
+ config.ManualSunset.Second(), 0, loc)
+
+ if sunset.Before(sunrise) {
+ sunset = sunset.Add(24 * time.Hour)
+ }
+ } else if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return now.Add(24 * time.Hour)
+ }
+ times := CalculateSunTimes(*lat, *lon, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else if config.Latitude != nil && config.Longitude != nil {
+ times := CalculateSunTimes(*config.Latitude, *config.Longitude, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else {
+ return now.Add(24 * time.Hour)
+ }
+
+ if now.Before(sunrise) {
+ return sunrise
+ }
+ if now.Before(sunset) {
+ return sunset
+ }
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Add(24 * time.Hour).Date()
+ loc := now.Location()
+ nextSunrise := time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ return nextSunrise
+ }
+
+ if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return now.Add(24 * time.Hour)
+ }
+ nextDayTimes := CalculateSunTimes(*lat, *lon, now.Add(24*time.Hour))
+ return nextDayTimes.Sunrise
+ }
+
+ if config.Latitude != nil && config.Longitude != nil {
+ nextDayTimes := CalculateSunTimes(*config.Latitude, *config.Longitude, now.Add(24*time.Hour))
+ return nextDayTimes.Sunrise
+ }
+
+ return now.Add(24 * time.Hour)
+}
+
+func TestCalculateSunTimes(t *testing.T) {
+ tests := []struct {
+ name string
+ lat float64
+ lon float64
+ date time.Time
+ checkFunc func(*testing.T, SunTimes)
+ }{
+ {
+ name: "new_york_summer",
+ lat: 40.7128,
+ lon: -74.0060,
+ date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 4 || times.Sunrise.Hour() > 6 {
+ t.Logf("sunrise: %v", times.Sunrise)
+ }
+ if times.Sunset.Hour() < 19 || times.Sunset.Hour() > 21 {
+ t.Logf("sunset: %v", times.Sunset)
+ }
+ if !times.Sunset.After(times.Sunrise) {
+ t.Error("sunset should be after sunrise")
+ }
+ },
+ },
+ {
+ name: "london_winter",
+ lat: 51.5074,
+ lon: -0.1278,
+ date: time.Date(2024, 12, 21, 12, 0, 0, 0, time.UTC),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 7 || times.Sunrise.Hour() > 9 {
+ t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour())
+ }
+ if times.Sunset.Hour() < 15 || times.Sunset.Hour() > 17 {
+ t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour())
+ }
+ },
+ },
+ {
+ name: "equator_equinox",
+ lat: 0.0,
+ lon: 0.0,
+ date: time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 5 || times.Sunrise.Hour() > 7 {
+ t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour())
+ }
+ if times.Sunset.Hour() < 17 || times.Sunset.Hour() > 19 {
+ t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour())
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ times := CalculateSunTimes(tt.lat, tt.lon, tt.date)
+ tt.checkFunc(t, times)
+ })
+ }
+}
+
+func TestCalculateTemperature(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: true,
+ }
+
+ times := CalculateSunTimes(lat, lon, date)
+
+ tests := []struct {
+ name string
+ timeFunc func() time.Time
+ wantTemp int
+ }{
+ {
+ name: "midnight",
+ timeFunc: func() time.Time { return times.Sunrise.Add(-4 * time.Hour) },
+ wantTemp: 4000,
+ },
+ {
+ name: "sunrise",
+ timeFunc: func() time.Time { return times.Sunrise },
+ wantTemp: 6500,
+ },
+ {
+ name: "noon",
+ timeFunc: func() time.Time { return times.Sunrise.Add(6 * time.Hour) },
+ wantTemp: 6500,
+ },
+ {
+ name: "after_sunset_transition",
+ timeFunc: func() time.Time { return times.Sunset.Add(2 * time.Hour) },
+ wantTemp: 4000,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ temp := calculateTemperature(config, tt.timeFunc())
+
+ if math.Abs(float64(temp-tt.wantTemp)) > 500 {
+ t.Errorf("temperature = %d, want approximately %d", temp, tt.wantTemp)
+ }
+ })
+ }
+}
+
+func TestCalculateTemperatureManualTimes(t *testing.T) {
+ sunrise := time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)
+ sunset := time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: &sunrise,
+ ManualSunset: &sunset,
+ Enabled: true,
+ }
+
+ tests := []struct {
+ name string
+ time time.Time
+ want int
+ }{
+ {"before_sunrise", time.Date(2024, 1, 1, 3, 0, 0, 0, time.Local), 4000},
+ {"at_sunrise", time.Date(2024, 1, 1, 6, 30, 0, 0, time.Local), 6500},
+ {"midday", time.Date(2024, 1, 1, 12, 0, 0, 0, time.Local), 6500},
+ {"at_sunset", time.Date(2024, 1, 1, 18, 30, 0, 0, time.Local), 6500},
+ {"after_sunset", time.Date(2024, 1, 1, 22, 0, 0, 0, time.Local), 4000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ temp := calculateTemperature(config, tt.time)
+ if math.Abs(float64(temp-tt.want)) > 500 {
+ t.Errorf("temperature = %d, want approximately %d", temp, tt.want)
+ }
+ })
+ }
+}
+
+func TestCalculateTemperatureDisabled(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: false,
+ }
+
+ temp := calculateTemperature(config, time.Now())
+ if temp != 6500 {
+ t.Errorf("disabled should return high temp, got %d", temp)
+ }
+}
+
+func TestCalculateNextTransition(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: true,
+ }
+
+ times := CalculateSunTimes(lat, lon, date)
+
+ tests := []struct {
+ name string
+ now time.Time
+ checkFunc func(*testing.T, time.Time)
+ }{
+ {
+ name: "before_sunrise",
+ now: times.Sunrise.Add(-2 * time.Hour),
+ checkFunc: func(t *testing.T, next time.Time) {
+ if !next.Equal(times.Sunrise) && !next.After(times.Sunrise.Add(-1*time.Minute)) {
+ t.Error("next transition should be at or near sunrise")
+ }
+ },
+ },
+ {
+ name: "after_sunrise",
+ now: times.Sunrise.Add(2 * time.Hour),
+ checkFunc: func(t *testing.T, next time.Time) {
+ if !next.After(times.Sunrise) {
+ t.Error("next transition should be after sunrise")
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ next := calculateNextTransition(config, tt.now)
+ tt.checkFunc(t, next)
+ })
+ }
+}
+
+func TestTimeOfDayToTime(t *testing.T) {
+ tests := []struct {
+ name string
+ hours float64
+ expected time.Time
+ }{
+ {
+ name: "noon",
+ hours: 12.0,
+ expected: time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local),
+ },
+ {
+ name: "half_past",
+ hours: 12.5,
+ expected: time.Date(2024, 6, 21, 12, 30, 0, 0, time.Local),
+ },
+ {
+ name: "early_morning",
+ hours: 6.25,
+ expected: time.Date(2024, 6, 21, 6, 15, 0, 0, time.Local),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := timeOfDayToTime(tt.hours, 2024, 6, 21, time.Local)
+
+ if result.Hour() != tt.expected.Hour() {
+ t.Errorf("hour = %d, want %d", result.Hour(), tt.expected.Hour())
+ }
+ if result.Minute() != tt.expected.Minute() {
+ t.Errorf("minute = %d, want %d", result.Minute(), tt.expected.Minute())
+ }
+ })
+ }
+}
diff --git a/backend/internal/server/wayland/types.go b/backend/internal/server/wayland/types.go
new file mode 100644
index 00000000..0690d2d3
--- /dev/null
+++ b/backend/internal/server/wayland/types.go
@@ -0,0 +1,194 @@
+package wayland
+
+import (
+ "math"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/godbus/dbus/v5"
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+)
+
+type Config struct {
+ Outputs []string
+ LowTemp int
+ HighTemp int
+ Latitude *float64
+ Longitude *float64
+ UseIPLocation bool
+ ManualSunrise *time.Time
+ ManualSunset *time.Time
+ ManualDuration *time.Duration
+ Gamma float64
+ Enabled bool
+}
+
+type State struct {
+ Config Config `json:"config"`
+ CurrentTemp int `json:"currentTemp"`
+ NextTransition time.Time `json:"nextTransition"`
+ SunriseTime time.Time `json:"sunriseTime"`
+ SunsetTime time.Time `json:"sunsetTime"`
+ IsDay bool `json:"isDay"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type Manager struct {
+ config Config
+ configMutex sync.RWMutex
+ state *State
+ stateMutex sync.RWMutex
+
+ display *wlclient.Display
+ registry *wlclient.Registry
+ gammaControl interface{}
+ availableOutputs []*wlclient.Output
+ outputRegNames map[uint32]uint32
+ outputs map[uint32]*outputState
+ outputsMutex sync.RWMutex
+ controlsInitialized bool
+
+ cmdq chan cmd
+ alive bool
+
+ stopChan chan struct{}
+ updateTrigger chan struct{}
+ wg sync.WaitGroup
+
+ currentTemp int
+ targetTemp int
+ transitionMutex sync.RWMutex
+ transitionChan chan int
+
+ cachedIPLat *float64
+ cachedIPLon *float64
+ locationMutex sync.RWMutex
+
+ subscribers map[string]chan State
+ subMutex sync.RWMutex
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ dbusConn *dbus.Conn
+ dbusSignal chan *dbus.Signal
+}
+
+type outputState struct {
+ id uint32
+ name string
+ registryName uint32
+ output *wlclient.Output
+ gammaControl interface{}
+ rampSize uint32
+ failed bool
+ isVirtual bool
+ retryCount int
+ lastFailTime time.Time
+}
+
+type SunTimes struct {
+ Sunrise time.Time
+ Sunset time.Time
+}
+
+func DefaultConfig() Config {
+ return Config{
+ Outputs: []string{},
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ Enabled: false,
+ }
+}
+
+func (c *Config) Validate() error {
+ if c.LowTemp < 1000 || c.LowTemp > 10000 {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.HighTemp < 1000 || c.HighTemp > 10000 {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.LowTemp > c.HighTemp {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.Gamma <= 0 || c.Gamma > 10 {
+ return errdefs.ErrInvalidGamma
+ }
+ if c.Latitude != nil && (math.Abs(*c.Latitude) > 90) {
+ return errdefs.ErrInvalidLocation
+ }
+ if c.Longitude != nil && (math.Abs(*c.Longitude) > 180) {
+ return errdefs.ErrInvalidLocation
+ }
+ if (c.Latitude != nil) != (c.Longitude != nil) {
+ return errdefs.ErrInvalidLocation
+ }
+ if (c.ManualSunrise != nil) != (c.ManualSunset != nil) {
+ return errdefs.ErrInvalidManualTimes
+ }
+ return nil
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{}
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.CurrentTemp != new.CurrentTemp {
+ return true
+ }
+ if old.IsDay != new.IsDay {
+ return true
+ }
+ if !old.NextTransition.Equal(new.NextTransition) {
+ return true
+ }
+ if !old.SunriseTime.Equal(new.SunriseTime) {
+ return true
+ }
+ if !old.SunsetTime.Equal(new.SunsetTime) {
+ return true
+ }
+ if old.Config.Enabled != new.Config.Enabled {
+ return true
+ }
+ return false
+}
diff --git a/backend/internal/server/wayland/types_test.go b/backend/internal/server/wayland/types_test.go
new file mode 100644
index 00000000..f5bbb428
--- /dev/null
+++ b/backend/internal/server/wayland/types_test.go
@@ -0,0 +1,330 @@
+package wayland
+
+import (
+ "testing"
+ "time"
+)
+
+func TestConfigValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ wantErr bool
+ }{
+ {
+ name: "valid_default",
+ config: DefaultConfig(),
+ wantErr: false,
+ },
+ {
+ name: "valid_with_location",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40.7128),
+ Longitude: floatPtr(-74.0060),
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ wantErr: false,
+ },
+ {
+ name: "valid_manual_times",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)),
+ ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid_low_temp_too_low",
+ config: Config{
+ LowTemp: 500,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_low_temp_too_high",
+ config: Config{
+ LowTemp: 15000,
+ HighTemp: 20000,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_high_temp_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 500,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "valid_temps_equal",
+ config: Config{
+ LowTemp: 5000,
+ HighTemp: 5000,
+ Gamma: 1.0,
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid_temps_reversed",
+ config: Config{
+ LowTemp: 6500,
+ HighTemp: 4000,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_zero",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_negative",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: -1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 15.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(100),
+ Longitude: floatPtr(0),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(-100),
+ Longitude: floatPtr(0),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Longitude: floatPtr(200),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Longitude: floatPtr(-200),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_without_longitude",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_without_latitude",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Longitude: floatPtr(-74),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_sunrise_without_sunset",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_sunset_without_sunrise",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.Validate()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestDefaultConfig(t *testing.T) {
+ config := DefaultConfig()
+
+ if config.LowTemp != 4000 {
+ t.Errorf("default low temp = %d, want 4000", config.LowTemp)
+ }
+ if config.HighTemp != 6500 {
+ t.Errorf("default high temp = %d, want 6500", config.HighTemp)
+ }
+ if config.Gamma != 1.0 {
+ t.Errorf("default gamma = %f, want 1.0", config.Gamma)
+ }
+ if config.Enabled {
+ t.Error("default should be disabled")
+ }
+ if config.Latitude != nil {
+ t.Error("default should not have latitude")
+ }
+ if config.Longitude != nil {
+ t.Error("default should not have longitude")
+ }
+}
+
+func TestStateChanged(t *testing.T) {
+ baseState := &State{
+ CurrentTemp: 5000,
+ NextTransition: time.Now(),
+ SunriseTime: time.Now().Add(6 * time.Hour),
+ SunsetTime: time.Now().Add(18 * time.Hour),
+ IsDay: true,
+ Config: DefaultConfig(),
+ }
+
+ tests := []struct {
+ name string
+ old *State
+ new *State
+ wantChanged bool
+ }{
+ {
+ name: "nil_old",
+ old: nil,
+ new: baseState,
+ wantChanged: true,
+ },
+ {
+ name: "nil_new",
+ old: baseState,
+ new: nil,
+ wantChanged: true,
+ },
+ {
+ name: "same_state",
+ old: baseState,
+ new: baseState,
+ wantChanged: false,
+ },
+ {
+ name: "temp_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: 6000,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: baseState.IsDay,
+ Config: baseState.Config,
+ },
+ wantChanged: true,
+ },
+ {
+ name: "is_day_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: baseState.CurrentTemp,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: false,
+ Config: baseState.Config,
+ },
+ wantChanged: true,
+ },
+ {
+ name: "enabled_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: baseState.CurrentTemp,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: baseState.IsDay,
+ Config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ },
+ wantChanged: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ changed := stateChanged(tt.old, tt.new)
+ if changed != tt.wantChanged {
+ t.Errorf("stateChanged() = %v, want %v", changed, tt.wantChanged)
+ }
+ })
+ }
+}
+
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+func timePtr(t time.Time) *time.Time {
+ return &t
+}
diff --git a/backend/internal/server/wlcontext/context.go b/backend/internal/server/wlcontext/context.go
new file mode 100644
index 00000000..007c0f2f
--- /dev/null
+++ b/backend/internal/server/wlcontext/context.go
@@ -0,0 +1,76 @@
+package wlcontext
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+)
+
+type SharedContext struct {
+ display *wlclient.Display
+ stopChan chan struct{}
+ wg sync.WaitGroup
+ mu sync.Mutex
+ started bool
+}
+
+func New() (*SharedContext, error) {
+ display, err := wlclient.Connect("")
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", errdefs.ErrNoWaylandDisplay, err)
+ }
+
+ sc := &SharedContext{
+ display: display,
+ stopChan: make(chan struct{}),
+ started: false,
+ }
+
+ return sc, nil
+}
+
+func (sc *SharedContext) Start() {
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+
+ if sc.started {
+ return
+ }
+
+ sc.started = true
+ sc.wg.Add(1)
+ go sc.eventDispatcher()
+}
+
+func (sc *SharedContext) Display() *wlclient.Display {
+ return sc.display
+}
+
+func (sc *SharedContext) eventDispatcher() {
+ defer sc.wg.Done()
+ ctx := sc.display.Context()
+
+ for {
+ select {
+ case <-sc.stopChan:
+ return
+ default:
+ if err := ctx.Dispatch(); err != nil {
+ log.Errorf("Wayland connection error: %v", err)
+ return
+ }
+ }
+ }
+}
+
+func (sc *SharedContext) Close() {
+ close(sc.stopChan)
+ sc.wg.Wait()
+
+ if sc.display != nil {
+ sc.display.Context().Close()
+ }
+}
diff --git a/backend/internal/server/wlroutput/handlers.go b/backend/internal/server/wlroutput/handlers.go
new file mode 100644
index 00000000..58192aa5
--- /dev/null
+++ b/backend/internal/server/wlroutput/handlers.go
@@ -0,0 +1,281 @@
+package wlroutput
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/proto/wlr_output_management"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
+)
+
+type Request struct {
+ ID int `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params map[string]interface{} `json:"params,omitempty"`
+}
+
+type SuccessResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+type HeadConfig struct {
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ ModeID *uint32 `json:"modeId,omitempty"`
+ CustomMode *struct {
+ Width int32 `json:"width"`
+ Height int32 `json:"height"`
+ Refresh int32 `json:"refresh"`
+ } `json:"customMode,omitempty"`
+ Position *struct{ X, Y int32 } `json:"position,omitempty"`
+ Transform *int32 `json:"transform,omitempty"`
+ Scale *float64 `json:"scale,omitempty"`
+ AdaptiveSync *uint32 `json:"adaptiveSync,omitempty"`
+}
+
+type ConfigurationRequest struct {
+ Heads []HeadConfig `json:"heads"`
+ Test bool `json:"test"`
+}
+
+func HandleRequest(conn net.Conn, req Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "wlroutput manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "wlroutput.getState":
+ handleGetState(conn, req, manager)
+ case "wlroutput.applyConfiguration":
+ handleApplyConfiguration(conn, req, manager, false)
+ case "wlroutput.testConfiguration":
+ handleApplyConfiguration(conn, req, manager, true)
+ case "wlroutput.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req Request, manager *Manager) {
+ state := manager.GetState()
+ models.Respond(conn, req.ID, state)
+}
+
+func handleApplyConfiguration(conn net.Conn, req Request, manager *Manager, test bool) {
+ headsParam, ok := req.Params["heads"]
+ if !ok {
+ models.RespondError(conn, req.ID, "missing 'heads' parameter")
+ return
+ }
+
+ headsJSON, err := json.Marshal(headsParam)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid 'heads' parameter format")
+ return
+ }
+
+ var heads []HeadConfig
+ if err := json.Unmarshal(headsJSON, &heads); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("invalid heads configuration: %v", err))
+ return
+ }
+
+ if err := manager.ApplyConfiguration(heads, test); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ msg := "configuration applied"
+ if test {
+ msg = "configuration test succeeded"
+ }
+ models.Respond(conn, req.ID, SuccessResult{Success: true, Message: msg})
+}
+
+func handleSubscribe(conn net.Conn, req Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func (m *Manager) ApplyConfiguration(heads []HeadConfig, test bool) error {
+ if m.manager == nil {
+ return fmt.Errorf("output manager not initialized")
+ }
+
+ resultChan := make(chan error, 1)
+
+ m.post(func() {
+ m.wlMutex.Lock()
+ defer m.wlMutex.Unlock()
+
+ config, err := m.manager.CreateConfiguration(m.serial)
+ if err != nil {
+ resultChan <- fmt.Errorf("failed to create configuration: %w", err)
+ return
+ }
+
+ statusChan := make(chan error, 1)
+
+ config.SetSucceededHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1SucceededEvent) {
+ log.Info("WlrOutput: configuration succeeded")
+ statusChan <- nil
+ })
+
+ config.SetFailedHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1FailedEvent) {
+ log.Warn("WlrOutput: configuration failed")
+ statusChan <- fmt.Errorf("compositor rejected configuration")
+ })
+
+ config.SetCancelledHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1CancelledEvent) {
+ log.Warn("WlrOutput: configuration cancelled")
+ statusChan <- fmt.Errorf("configuration cancelled (outdated serial)")
+ })
+
+ m.headsMutex.RLock()
+ headsByName := make(map[string]*headState)
+ for _, head := range m.heads {
+ if !head.finished {
+ headsByName[head.name] = head
+ }
+ }
+ m.headsMutex.RUnlock()
+
+ for _, headCfg := range heads {
+ head, exists := headsByName[headCfg.Name]
+ if !exists {
+ config.Destroy()
+ resultChan <- fmt.Errorf("head not found: %s", headCfg.Name)
+ return
+ }
+
+ if !headCfg.Enabled {
+ if err := config.DisableHead(head.handle); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to disable head %s: %w", headCfg.Name, err)
+ return
+ }
+ continue
+ }
+
+ headConfig, err := config.EnableHead(head.handle)
+ if err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to enable head %s: %w", headCfg.Name, err)
+ return
+ }
+
+ if headCfg.ModeID != nil {
+ m.modesMutex.RLock()
+ mode, exists := m.modes[*headCfg.ModeID]
+ m.modesMutex.RUnlock()
+
+ if !exists {
+ config.Destroy()
+ resultChan <- fmt.Errorf("mode not found: %d", *headCfg.ModeID)
+ return
+ }
+
+ if err := headConfig.SetMode(mode.handle); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set mode for %s: %w", headCfg.Name, err)
+ return
+ }
+ } else if headCfg.CustomMode != nil {
+ if err := headConfig.SetCustomMode(
+ headCfg.CustomMode.Width,
+ headCfg.CustomMode.Height,
+ headCfg.CustomMode.Refresh,
+ ); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set custom mode for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.Position != nil {
+ if err := headConfig.SetPosition(headCfg.Position.X, headCfg.Position.Y); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set position for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.Transform != nil {
+ if err := headConfig.SetTransform(*headCfg.Transform); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set transform for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.Scale != nil {
+ if err := headConfig.SetScale(*headCfg.Scale); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set scale for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+
+ if headCfg.AdaptiveSync != nil {
+ if err := headConfig.SetAdaptiveSync(*headCfg.AdaptiveSync); err != nil {
+ config.Destroy()
+ resultChan <- fmt.Errorf("failed to set adaptive sync for %s: %w", headCfg.Name, err)
+ return
+ }
+ }
+ }
+
+ var applyErr error
+ if test {
+ applyErr = config.Test()
+ } else {
+ applyErr = config.Apply()
+ }
+
+ if applyErr != nil {
+ config.Destroy()
+ action := "apply"
+ if test {
+ action = "test"
+ }
+ resultChan <- fmt.Errorf("failed to %s configuration: %w", action, applyErr)
+ return
+ }
+
+ go func() {
+ select {
+ case err := <-statusChan:
+ config.Destroy()
+ resultChan <- err
+ case <-time.After(5 * time.Second):
+ config.Destroy()
+ resultChan <- fmt.Errorf("timeout waiting for configuration response")
+ }
+ }()
+ })
+
+ return <-resultChan
+}
diff --git a/backend/internal/server/wlroutput/manager.go b/backend/internal/server/wlroutput/manager.go
new file mode 100644
index 00000000..28c124e8
--- /dev/null
+++ b/backend/internal/server/wlroutput/manager.go
@@ -0,0 +1,511 @@
+package wlroutput
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/proto/wlr_output_management"
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+)
+
+func NewManager(display *wlclient.Display) (*Manager, error) {
+ m := &Manager{
+ display: display,
+ heads: make(map[uint32]*headState),
+ modes: make(map[uint32]*modeState),
+ cmdq: make(chan cmd, 128),
+ stopChan: make(chan struct{}),
+ subscribers: make(map[string]chan State),
+ dirty: make(chan struct{}, 1),
+ fatalError: make(chan error, 1),
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ if err := m.setupRegistry(); err != nil {
+ close(m.stopChan)
+ m.wg.Wait()
+ return nil, err
+ }
+
+ m.updateState()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("WlrOutput actor command queue full, dropping command")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+ defer func() {
+ if r := recover(); r != nil {
+ err := fmt.Errorf("waylandActor panic: %v", r)
+ log.Errorf("WlrOutput: %v", err)
+
+ select {
+ case m.fatalError <- err:
+ default:
+ }
+
+ select {
+ case <-m.stopChan:
+ default:
+ close(m.stopChan)
+ }
+ }
+ }()
+
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("WlrOutput: command execution panic: %v", r)
+ }
+ }()
+ c.fn()
+ }()
+ }
+ }
+}
+
+func (m *Manager) setupRegistry() error {
+ log.Info("WlrOutput: starting registry setup")
+ ctx := m.display.Context()
+
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("failed to get registry: %w", err)
+ }
+ m.registry = registry
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName {
+ log.Infof("WlrOutput: found %s", wlr_output_management.ZwlrOutputManagerV1InterfaceName)
+ manager := wlr_output_management.NewZwlrOutputManagerV1(ctx)
+ version := e.Version
+ if version > 4 {
+ version = 4
+ }
+
+ manager.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
+ m.handleHead(e)
+ })
+
+ manager.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) {
+ log.Debugf("WlrOutput: done event received (serial=%d)", e.Serial)
+ m.serial = e.Serial
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ manager.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputManagerV1FinishedEvent) {
+ log.Info("WlrOutput: finished event received")
+ })
+
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ m.manager = manager
+ log.Info("WlrOutput: manager bound successfully")
+ } else {
+ log.Errorf("WlrOutput: failed to bind manager: %v", err)
+ }
+ }
+ })
+
+ log.Info("WlrOutput: registry setup complete (events will be processed async)")
+ return nil
+}
+
+func (m *Manager) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
+ handle := e.Head
+ headID := handle.ID()
+
+ log.Debugf("WlrOutput: New head (id=%d)", headID)
+
+ head := &headState{
+ id: headID,
+ handle: handle,
+ modeIDs: make([]uint32, 0),
+ }
+
+ m.headsMutex.Lock()
+ m.heads[headID] = head
+ m.headsMutex.Unlock()
+
+ handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) {
+ log.Debugf("WlrOutput: Head %d name: %s", headID, e.Name)
+ head.name = e.Name
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetDescriptionHandler(func(e wlr_output_management.ZwlrOutputHeadV1DescriptionEvent) {
+ log.Debugf("WlrOutput: Head %d description: %s", headID, e.Description)
+ head.description = e.Description
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetPhysicalSizeHandler(func(e wlr_output_management.ZwlrOutputHeadV1PhysicalSizeEvent) {
+ log.Debugf("WlrOutput: Head %d physical size: %dx%d", headID, e.Width, e.Height)
+ head.physicalWidth = e.Width
+ head.physicalHeight = e.Height
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
+ m.handleMode(headID, e)
+ })
+
+ handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) {
+ log.Debugf("WlrOutput: Head %d enabled: %d", headID, e.Enabled)
+ head.enabled = e.Enabled != 0
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) {
+ modeID := e.Mode.ID()
+ log.Debugf("WlrOutput: Head %d current mode: %d", headID, modeID)
+ head.currentModeID = modeID
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetPositionHandler(func(e wlr_output_management.ZwlrOutputHeadV1PositionEvent) {
+ log.Debugf("WlrOutput: Head %d position: %d,%d", headID, e.X, e.Y)
+ head.x = e.X
+ head.y = e.Y
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetTransformHandler(func(e wlr_output_management.ZwlrOutputHeadV1TransformEvent) {
+ log.Debugf("WlrOutput: Head %d transform: %d", headID, e.Transform)
+ head.transform = e.Transform
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
+ log.Debugf("WlrOutput: Head %d scale: %f", headID, e.Scale)
+ head.scale = e.Scale
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetMakeHandler(func(e wlr_output_management.ZwlrOutputHeadV1MakeEvent) {
+ log.Debugf("WlrOutput: Head %d make: %s", headID, e.Make)
+ head.make = e.Make
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetModelHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModelEvent) {
+ log.Debugf("WlrOutput: Head %d model: %s", headID, e.Model)
+ head.model = e.Model
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetSerialNumberHandler(func(e wlr_output_management.ZwlrOutputHeadV1SerialNumberEvent) {
+ log.Debugf("WlrOutput: Head %d serial: %s", headID, e.SerialNumber)
+ head.serialNumber = e.SerialNumber
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetAdaptiveSyncHandler(func(e wlr_output_management.ZwlrOutputHeadV1AdaptiveSyncEvent) {
+ log.Debugf("WlrOutput: Head %d adaptive sync: %d", headID, e.State)
+ head.adaptiveSync = e.State
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputHeadV1FinishedEvent) {
+ log.Debugf("WlrOutput: Head %d finished", headID)
+ head.finished = true
+
+ m.headsMutex.Lock()
+ delete(m.heads, headID)
+ m.headsMutex.Unlock()
+
+ m.post(func() {
+ m.wlMutex.Lock()
+ handle.Release()
+ m.wlMutex.Unlock()
+
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) handleMode(headID uint32, e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
+ handle := e.Mode
+ modeID := handle.ID()
+
+ log.Debugf("WlrOutput: Head %d new mode (id=%d)", headID, modeID)
+
+ mode := &modeState{
+ id: modeID,
+ handle: handle,
+ }
+
+ m.modesMutex.Lock()
+ m.modes[modeID] = mode
+ m.modesMutex.Unlock()
+
+ m.headsMutex.Lock()
+ if head, ok := m.heads[headID]; ok {
+ head.modeIDs = append(head.modeIDs, modeID)
+ }
+ m.headsMutex.Unlock()
+
+ handle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) {
+ log.Debugf("WlrOutput: Mode %d size: %dx%d", modeID, e.Width, e.Height)
+ mode.width = e.Width
+ mode.height = e.Height
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) {
+ log.Debugf("WlrOutput: Mode %d refresh: %d", modeID, e.Refresh)
+ mode.refresh = e.Refresh
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetPreferredHandler(func(e wlr_output_management.ZwlrOutputModeV1PreferredEvent) {
+ log.Debugf("WlrOutput: Mode %d preferred", modeID)
+ mode.preferred = true
+ m.post(func() {
+ m.updateState()
+ })
+ })
+
+ handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputModeV1FinishedEvent) {
+ log.Debugf("WlrOutput: Mode %d finished", modeID)
+ mode.finished = true
+
+ m.modesMutex.Lock()
+ delete(m.modes, modeID)
+ m.modesMutex.Unlock()
+
+ m.post(func() {
+ m.wlMutex.Lock()
+ handle.Release()
+ m.wlMutex.Unlock()
+
+ m.updateState()
+ })
+ })
+}
+
+func (m *Manager) updateState() {
+ m.headsMutex.RLock()
+ m.modesMutex.RLock()
+
+ outputs := make([]Output, 0)
+
+ for _, head := range m.heads {
+ if head.finished {
+ continue
+ }
+
+ modes := make([]OutputMode, 0)
+ var currentMode *OutputMode
+
+ for _, modeID := range head.modeIDs {
+ mode, exists := m.modes[modeID]
+ if !exists || mode.finished {
+ continue
+ }
+
+ outMode := OutputMode{
+ Width: mode.width,
+ Height: mode.height,
+ Refresh: mode.refresh,
+ Preferred: mode.preferred,
+ ID: modeID,
+ }
+ modes = append(modes, outMode)
+
+ if modeID == head.currentModeID {
+ currentMode = &outMode
+ }
+ }
+
+ output := Output{
+ Name: head.name,
+ Description: head.description,
+ Make: head.make,
+ Model: head.model,
+ SerialNumber: head.serialNumber,
+ PhysicalWidth: head.physicalWidth,
+ PhysicalHeight: head.physicalHeight,
+ Enabled: head.enabled,
+ X: head.x,
+ Y: head.y,
+ Transform: head.transform,
+ Scale: head.scale,
+ CurrentMode: currentMode,
+ Modes: modes,
+ AdaptiveSync: head.adaptiveSync,
+ ID: head.id,
+ }
+ outputs = append(outputs, output)
+ }
+
+ m.modesMutex.RUnlock()
+ m.headsMutex.RUnlock()
+
+ newState := State{
+ Outputs: outputs,
+ Serial: m.serial,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ defer func() {
+ if r := recover(); r != nil {
+ err := fmt.Errorf("notifier panic: %v", r)
+ log.Errorf("WlrOutput: %v", err)
+
+ select {
+ case m.fatalError <- err:
+ default:
+ }
+
+ select {
+ case <-m.stopChan:
+ default:
+ close(m.stopChan)
+ }
+ }
+ }()
+
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.subMutex.RLock()
+ subCount := len(m.subscribers)
+ m.subMutex.RUnlock()
+
+ if subCount == 0 {
+ pending = false
+ continue
+ }
+
+ currentState := m.GetState()
+
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) {
+ pending = false
+ continue
+ }
+
+ m.subMutex.RLock()
+ for _, ch := range m.subscribers {
+ select {
+ case ch <- currentState:
+ default:
+ log.Warn("WlrOutput: subscriber channel full, dropping update")
+ }
+ }
+ m.subMutex.RUnlock()
+
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subMutex.Lock()
+ for _, ch := range m.subscribers {
+ close(ch)
+ }
+ m.subscribers = make(map[string]chan State)
+ m.subMutex.Unlock()
+
+ m.modesMutex.Lock()
+ for _, mode := range m.modes {
+ if mode.handle != nil {
+ mode.handle.Release()
+ }
+ }
+ m.modes = make(map[uint32]*modeState)
+ m.modesMutex.Unlock()
+
+ m.headsMutex.Lock()
+ for _, head := range m.heads {
+ if head.handle != nil {
+ head.handle.Release()
+ }
+ }
+ m.heads = make(map[uint32]*headState)
+ m.headsMutex.Unlock()
+
+ if m.manager != nil {
+ m.manager.Stop()
+ }
+}
diff --git a/backend/internal/server/wlroutput/types.go b/backend/internal/server/wlroutput/types.go
new file mode 100644
index 00000000..ba5d3f1e
--- /dev/null
+++ b/backend/internal/server/wlroutput/types.go
@@ -0,0 +1,191 @@
+package wlroutput
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/proto/wlr_output_management"
+ wlclient "github.com/yaslama/go-wayland/wayland/client"
+)
+
+type OutputMode struct {
+ Width int32 `json:"width"`
+ Height int32 `json:"height"`
+ Refresh int32 `json:"refresh"`
+ Preferred bool `json:"preferred"`
+ ID uint32 `json:"id"`
+}
+
+type Output struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Make string `json:"make"`
+ Model string `json:"model"`
+ SerialNumber string `json:"serialNumber"`
+ PhysicalWidth int32 `json:"physicalWidth"`
+ PhysicalHeight int32 `json:"physicalHeight"`
+ Enabled bool `json:"enabled"`
+ X int32 `json:"x"`
+ Y int32 `json:"y"`
+ Transform int32 `json:"transform"`
+ Scale float64 `json:"scale"`
+ CurrentMode *OutputMode `json:"currentMode"`
+ Modes []OutputMode `json:"modes"`
+ AdaptiveSync uint32 `json:"adaptiveSync"`
+ ID uint32 `json:"id"`
+}
+
+type State struct {
+ Outputs []Output `json:"outputs"`
+ Serial uint32 `json:"serial"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type Manager struct {
+ display *wlclient.Display
+ registry *wlclient.Registry
+ manager *wlr_output_management.ZwlrOutputManagerV1
+
+ headsMutex sync.RWMutex
+ heads map[uint32]*headState
+
+ modesMutex sync.RWMutex
+ modes map[uint32]*modeState
+
+ serial uint32
+
+ wlMutex sync.Mutex
+ cmdq chan cmd
+ stopChan chan struct{}
+ wg sync.WaitGroup
+
+ subscribers map[string]chan State
+ subMutex sync.RWMutex
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ stateMutex sync.RWMutex
+ state *State
+
+ fatalError chan error
+}
+
+type headState struct {
+ id uint32
+ handle *wlr_output_management.ZwlrOutputHeadV1
+ name string
+ description string
+ make string
+ model string
+ serialNumber string
+ physicalWidth int32
+ physicalHeight int32
+ enabled bool
+ x int32
+ y int32
+ transform int32
+ scale float64
+ currentModeID uint32
+ modeIDs []uint32
+ adaptiveSync uint32
+ finished bool
+}
+
+type modeState struct {
+ id uint32
+ handle *wlr_output_management.ZwlrOutputModeV1
+ width int32
+ height int32
+ refresh int32
+ preferred bool
+ finished bool
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{
+ Outputs: []Output{},
+ Serial: 0,
+ }
+ }
+ stateCopy := *m.state
+ return stateCopy
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) FatalError() <-chan error {
+ return m.fatalError
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.Serial != new.Serial {
+ return true
+ }
+ if len(old.Outputs) != len(new.Outputs) {
+ return true
+ }
+ for i := range new.Outputs {
+ if i >= len(old.Outputs) {
+ return true
+ }
+ oldOut := &old.Outputs[i]
+ newOut := &new.Outputs[i]
+ if oldOut.Name != newOut.Name || oldOut.Enabled != newOut.Enabled {
+ return true
+ }
+ if oldOut.X != newOut.X || oldOut.Y != newOut.Y {
+ return true
+ }
+ if oldOut.Transform != newOut.Transform || oldOut.Scale != newOut.Scale {
+ return true
+ }
+ if oldOut.AdaptiveSync != newOut.AdaptiveSync {
+ return true
+ }
+ if (oldOut.CurrentMode == nil) != (newOut.CurrentMode == nil) {
+ return true
+ }
+ if oldOut.CurrentMode != nil && newOut.CurrentMode != nil {
+ if oldOut.CurrentMode.Width != newOut.CurrentMode.Width ||
+ oldOut.CurrentMode.Height != newOut.CurrentMode.Height ||
+ oldOut.CurrentMode.Refresh != newOut.CurrentMode.Refresh {
+ return true
+ }
+ }
+ if len(oldOut.Modes) != len(newOut.Modes) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/sway/keybinds.go b/backend/internal/sway/keybinds.go
new file mode 100644
index 00000000..9df21049
--- /dev/null
+++ b/backend/internal/sway/keybinds.go
@@ -0,0 +1,367 @@
+package sway
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+const (
+ TitleRegex = "#+!"
+ HideComment = "[hidden]"
+)
+
+var ModSeparators = []rune{'+', ' '}
+
+type KeyBinding struct {
+ Mods []string `json:"mods"`
+ Key string `json:"key"`
+ Command string `json:"command"`
+ 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
+ variables map[string]string
+}
+
+func NewParser() *Parser {
+ return &Parser{
+ contentLines: []string{},
+ readingLine: 0,
+ variables: make(map[string]string),
+ }
+}
+
+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() {
+ mainConfig := filepath.Join(expandedPath, "config")
+ if fileInfo, err := os.Stat(mainConfig); err == nil && fileInfo.Mode().IsRegular() {
+ files = []string{mainConfig}
+ } else {
+ return os.ErrNotExist
+ }
+ } else {
+ files = []string{expandedPath}
+ }
+
+ var combinedContent []string
+ for _, file := range files {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ return err
+ }
+ combinedContent = append(combinedContent, string(data))
+ }
+
+ if len(combinedContent) == 0 {
+ return os.ErrNotExist
+ }
+
+ fullContent := strings.Join(combinedContent, "\n")
+ p.contentLines = strings.Split(fullContent, "\n")
+ p.parseVariables()
+ return nil
+}
+
+func (p *Parser) parseVariables() {
+ setRegex := regexp.MustCompile(`^\s*set\s+\$(\w+)\s+(.+)$`)
+ for _, line := range p.contentLines {
+ matches := setRegex.FindStringSubmatch(line)
+ if len(matches) == 3 {
+ varName := matches[1]
+ varValue := strings.TrimSpace(matches[2])
+ p.variables[varName] = varValue
+ }
+ }
+}
+
+func (p *Parser) expandVariables(text string) string {
+ result := text
+ for varName, varValue := range p.variables {
+ result = strings.ReplaceAll(result, "$"+varName, varValue)
+ }
+ return result
+}
+
+func autogenerateComment(command string) string {
+ command = strings.TrimSpace(command)
+
+ if strings.HasPrefix(command, "exec ") {
+ cmdPart := strings.TrimPrefix(command, "exec ")
+ cmdPart = strings.TrimPrefix(cmdPart, "--no-startup-id ")
+ return cmdPart
+ }
+
+ switch {
+ case command == "kill":
+ return "Close window"
+ case command == "exit":
+ return "Exit Sway"
+ case command == "reload":
+ return "Reload configuration"
+ case strings.HasPrefix(command, "fullscreen"):
+ return "Toggle fullscreen"
+ case strings.HasPrefix(command, "floating toggle"):
+ return "Float/unfloat window"
+ case strings.HasPrefix(command, "focus mode_toggle"):
+ return "Toggle focus mode"
+ case strings.HasPrefix(command, "focus parent"):
+ return "Focus parent container"
+ case strings.HasPrefix(command, "focus left"):
+ return "Focus left"
+ case strings.HasPrefix(command, "focus right"):
+ return "Focus right"
+ case strings.HasPrefix(command, "focus up"):
+ return "Focus up"
+ case strings.HasPrefix(command, "focus down"):
+ return "Focus down"
+ case strings.HasPrefix(command, "focus output"):
+ return "Focus monitor"
+ case strings.HasPrefix(command, "move left"):
+ return "Move window left"
+ case strings.HasPrefix(command, "move right"):
+ return "Move window right"
+ case strings.HasPrefix(command, "move up"):
+ return "Move window up"
+ case strings.HasPrefix(command, "move down"):
+ return "Move window down"
+ case strings.HasPrefix(command, "move container to workspace"):
+ if strings.Contains(command, "prev") {
+ return "Move to previous workspace"
+ }
+ if strings.Contains(command, "next") {
+ return "Move to next workspace"
+ }
+ parts := strings.Fields(command)
+ if len(parts) > 4 {
+ return "Move to workspace " + parts[len(parts)-1]
+ }
+ return "Move to workspace"
+ case strings.HasPrefix(command, "move workspace to output"):
+ return "Move workspace to monitor"
+ case strings.HasPrefix(command, "workspace"):
+ if strings.Contains(command, "prev") {
+ return "Previous workspace"
+ }
+ if strings.Contains(command, "next") {
+ return "Next workspace"
+ }
+ parts := strings.Fields(command)
+ if len(parts) > 1 {
+ wsNum := parts[len(parts)-1]
+ return "Workspace " + wsNum
+ }
+ return "Switch workspace"
+ case strings.HasPrefix(command, "layout"):
+ parts := strings.Fields(command)
+ if len(parts) > 1 {
+ return "Layout " + parts[1]
+ }
+ return "Change layout"
+ case strings.HasPrefix(command, "split"):
+ if strings.Contains(command, "h") {
+ return "Split horizontal"
+ }
+ if strings.Contains(command, "v") {
+ return "Split vertical"
+ }
+ return "Split container"
+ case strings.HasPrefix(command, "resize"):
+ return "Resize window"
+ case strings.Contains(command, "scratchpad"):
+ return "Toggle scratchpad"
+ default:
+ return command
+ }
+}
+
+func (p *Parser) getKeybindAtLine(lineNumber int) *KeyBinding {
+ if lineNumber >= len(p.contentLines) {
+ return nil
+ }
+
+ line := p.contentLines[lineNumber]
+
+ bindMatch := regexp.MustCompile(`^\s*(bindsym|bindcode)\s+(.+)$`)
+ matches := bindMatch.FindStringSubmatch(line)
+ if len(matches) < 3 {
+ return nil
+ }
+
+ content := matches[2]
+
+ parts := strings.SplitN(content, "#", 2)
+ keys := strings.TrimSpace(parts[0])
+
+ var comment string
+ if len(parts) > 1 {
+ comment = strings.TrimSpace(parts[1])
+ }
+
+ if strings.HasPrefix(comment, HideComment) {
+ return nil
+ }
+
+ flags := ""
+ if strings.HasPrefix(keys, "--") {
+ spaceIdx := strings.Index(keys, " ")
+ if spaceIdx > 0 {
+ flags = keys[:spaceIdx]
+ keys = strings.TrimSpace(keys[spaceIdx+1:])
+ }
+ }
+
+ keyParts := strings.Fields(keys)
+ if len(keyParts) < 2 {
+ return nil
+ }
+
+ keyCombo := keyParts[0]
+ keyCombo = p.expandVariables(keyCombo)
+ command := strings.Join(keyParts[1:], " ")
+ command = p.expandVariables(command)
+
+ var modList []string
+ var key string
+
+ modstring := keyCombo + string(ModSeparators[0])
+ pos := 0
+ for index, char := range modstring {
+ isModSep := false
+ for _, sep := range ModSeparators {
+ if char == sep {
+ isModSep = true
+ break
+ }
+ }
+ if isModSep {
+ if index-pos > 0 {
+ part := modstring[pos:index]
+ if isMod(part) {
+ modList = append(modList, part)
+ } else {
+ key = part
+ }
+ }
+ pos = index + 1
+ }
+ }
+
+ if comment == "" {
+ comment = autogenerateComment(command)
+ }
+
+ _ = flags
+
+ return &KeyBinding{
+ Mods: modList,
+ Key: key,
+ Command: command,
+ Comment: comment,
+ }
+}
+
+func isMod(s string) bool {
+ s = strings.ToLower(s)
+ if s == "mod1" || s == "mod2" || s == "mod3" || s == "mod4" || s == "mod5" ||
+ s == "shift" || s == "control" || s == "ctrl" || s == "alt" || s == "super" ||
+ strings.HasPrefix(s, "$") {
+ return true
+ }
+
+ isNumeric := true
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ isNumeric = false
+ break
+ }
+ }
+ if isNumeric && len(s) >= 2 {
+ return true
+ }
+ return false
+}
+
+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 line == "" || (!strings.Contains(line, "bindsym") && !strings.Contains(line, "bindcode")) {
+
+ } 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
+}
diff --git a/backend/internal/sway/keybinds_test.go b/backend/internal/sway/keybinds_test.go
new file mode 100644
index 00000000..af5fd15e
--- /dev/null
+++ b/backend/internal/sway/keybinds_test.go
@@ -0,0 +1,471 @@
+package sway
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestAutogenerateComment(t *testing.T) {
+ tests := []struct {
+ command string
+ expected string
+ }{
+ {"exec kitty", "kitty"},
+ {"exec --no-startup-id firefox", "firefox"},
+ {"kill", "Close window"},
+ {"exit", "Exit Sway"},
+ {"reload", "Reload configuration"},
+ {"fullscreen toggle", "Toggle fullscreen"},
+ {"floating toggle", "Float/unfloat window"},
+ {"focus mode_toggle", "Toggle focus mode"},
+ {"focus parent", "Focus parent container"},
+ {"focus left", "Focus left"},
+ {"focus right", "Focus right"},
+ {"focus up", "Focus up"},
+ {"focus down", "Focus down"},
+ {"focus output left", "Focus monitor"},
+ {"move left", "Move window left"},
+ {"move right", "Move window right"},
+ {"move up", "Move window up"},
+ {"move down", "Move window down"},
+ {"move container to workspace number 1", "Move to workspace 1"},
+ {"move container to workspace prev", "Move to previous workspace"},
+ {"move container to workspace next", "Move to next workspace"},
+ {"move workspace to output left", "Move workspace to monitor"},
+ {"workspace number 1", "Workspace 1"},
+ {"workspace prev", "Previous workspace"},
+ {"workspace next", "Next workspace"},
+ {"layout tabbed", "Layout tabbed"},
+ {"layout stacking", "Layout stacking"},
+ {"splith", "Split horizontal"},
+ {"splitv", "Split vertical"},
+ {"resize grow width 10 ppt", "Resize window"},
+ {"move scratchpad", "Toggle scratchpad"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.command, func(t *testing.T) {
+ result := autogenerateComment(tt.command)
+ if result != tt.expected {
+ t.Errorf("autogenerateComment(%q) = %q, want %q",
+ tt.command, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGetKeybindAtLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expected *KeyBinding
+ }{
+ {
+ name: "basic_keybind",
+ line: "bindsym Mod4+q kill",
+ expected: &KeyBinding{
+ Mods: []string{"Mod4"},
+ Key: "q",
+ Command: "kill",
+ Comment: "Close window",
+ },
+ },
+ {
+ name: "keybind_with_exec",
+ line: "bindsym Mod4+t exec kitty",
+ expected: &KeyBinding{
+ Mods: []string{"Mod4"},
+ Key: "t",
+ Command: "exec kitty",
+ Comment: "kitty",
+ },
+ },
+ {
+ name: "keybind_with_comment",
+ line: "bindsym Mod4+Space exec dms ipc call spotlight toggle # Open launcher",
+ expected: &KeyBinding{
+ Mods: []string{"Mod4"},
+ Key: "Space",
+ Command: "exec dms ipc call spotlight toggle",
+ Comment: "Open launcher",
+ },
+ },
+ {
+ name: "keybind_hidden",
+ line: "bindsym Mod4+h exec secret # [hidden]",
+ expected: nil,
+ },
+ {
+ name: "keybind_multiple_mods",
+ line: "bindsym Mod4+Shift+e exit",
+ expected: &KeyBinding{
+ Mods: []string{"Mod4", "Shift"},
+ Key: "e",
+ Command: "exit",
+ Comment: "Exit Sway",
+ },
+ },
+ {
+ name: "keybind_no_mods",
+ line: "bindsym Print exec grim screenshot.png",
+ expected: &KeyBinding{
+ Mods: []string{},
+ Key: "Print",
+ Command: "exec grim screenshot.png",
+ Comment: "grim screenshot.png",
+ },
+ },
+ {
+ name: "keybind_with_flags",
+ line: "bindsym --release Mod4+x exec notify-send released",
+ expected: &KeyBinding{
+ Mods: []string{"Mod4"},
+ Key: "x",
+ Command: "exec notify-send released",
+ Comment: "notify-send released",
+ },
+ },
+ {
+ name: "keybind_focus_direction",
+ line: "bindsym Mod4+Left focus left",
+ expected: &KeyBinding{
+ Mods: []string{"Mod4"},
+ Key: "Left",
+ Command: "focus left",
+ Comment: "Focus left",
+ },
+ },
+ {
+ name: "keybind_workspace",
+ line: "bindsym Mod4+1 workspace number 1",
+ expected: &KeyBinding{
+ Mods: []string{"Mod4"},
+ Key: "1",
+ Command: "workspace number 1",
+ Comment: "Workspace 1",
+ },
+ },
+ }
+
+ 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.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 TestVariableExpansion(t *testing.T) {
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, "config")
+
+ content := `set $mod Mod4
+set $term kitty
+set $menu rofi
+
+bindsym $mod+t exec $term
+bindsym $mod+d exec $menu
+`
+
+ if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
+ t.Fatalf("Failed to write test config: %v", err)
+ }
+
+ section, err := ParseKeys(configFile)
+ if err != nil {
+ t.Fatalf("ParseKeys failed: %v", err)
+ }
+
+ if len(section.Keybinds) != 2 {
+ t.Errorf("Expected 2 keybinds, got %d", len(section.Keybinds))
+ }
+
+ if len(section.Keybinds) > 0 {
+ if section.Keybinds[0].Mods[0] != "Mod4" {
+ t.Errorf("Expected Mod4, got %q", section.Keybinds[0].Mods[0])
+ }
+ if section.Keybinds[0].Command != "exec kitty" {
+ t.Errorf("Expected 'exec kitty', got %q", section.Keybinds[0].Command)
+ }
+ }
+
+ if len(section.Keybinds) > 1 {
+ if section.Keybinds[1].Command != "exec rofi" {
+ t.Errorf("Expected 'exec rofi', got %q", section.Keybinds[1].Command)
+ }
+ }
+}
+
+func TestParseKeysWithSections(t *testing.T) {
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, "config")
+
+ content := `set $mod Mod4
+
+##! Window Management
+bindsym $mod+q kill
+bindsym $mod+f fullscreen toggle
+
+###! Focus
+bindsym $mod+Left focus left
+bindsym $mod+Right focus right
+
+##! Applications
+bindsym $mod+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 {
+ focus := windowMgmt.Children[0]
+ if focus.Name != "Focus" {
+ t.Errorf("Focus section name = %q, want %q", focus.Name, "Focus")
+ }
+ if len(focus.Keybinds) != 2 {
+ t.Errorf("Focus keybinds = %d, want 2", len(focus.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 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-sway-"+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")
+ if err := os.WriteFile(configFile, []byte("bindsym Mod4+q kill\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")
+
+ content := `
+# This is a comment
+bindsym Mod4+q kill
+
+# Another comment
+
+bindsym Mod4+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(configFile)
+ 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))
+ }
+}
+
+func TestRealWorldConfig(t *testing.T) {
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, "config")
+
+ content := `set $mod Mod4
+set $term kitty
+
+## Application Launchers
+bindsym $mod+t exec $term
+bindsym $mod+Space exec rofi
+
+## Window Management
+bindsym $mod+q kill
+bindsym $mod+f fullscreen toggle
+
+## Focus Navigation
+bindsym $mod+Left focus left
+bindsym $mod+Right focus right
+
+## Workspace Navigation
+bindsym $mod+1 workspace number 1
+bindsym $mod+2 workspace number 2
+bindsym $mod+Shift+1 move container to workspace number 1
+`
+
+ if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
+ t.Fatalf("Failed to write test config: %v", err)
+ }
+
+ section, err := ParseKeys(configFile)
+ if err != nil {
+ t.Fatalf("ParseKeys failed: %v", err)
+ }
+
+ if len(section.Keybinds) < 9 {
+ t.Errorf("Expected at least 9 keybinds, got %d", len(section.Keybinds))
+ }
+
+ foundExec := false
+ foundKill := false
+ foundWorkspace := false
+
+ for _, kb := range section.Keybinds {
+ if kb.Command == "exec kitty" {
+ foundExec = true
+ }
+ if kb.Command == "kill" {
+ foundKill = true
+ }
+ if kb.Command == "workspace number 1" {
+ foundWorkspace = true
+ }
+ }
+
+ if !foundExec {
+ t.Error("Did not find exec kitty keybind")
+ }
+ if !foundKill {
+ t.Error("Did not find kill keybind")
+ }
+ if !foundWorkspace {
+ t.Error("Did not find workspace 1 keybind")
+ }
+}
+
+func TestIsMod(t *testing.T) {
+ tests := []struct {
+ input string
+ expected bool
+ }{
+ {"Mod4", true},
+ {"Shift", true},
+ {"Control", true},
+ {"Alt", true},
+ {"Super", true},
+ {"$mod", true},
+ {"Left", false},
+ {"q", false},
+ {"1", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ result := isMod(tt.input)
+ if result != tt.expected {
+ t.Errorf("isMod(%q) = %v, want %v", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/backend/internal/tui/app.go b/backend/internal/tui/app.go
new file mode 100644
index 00000000..7a8d5640
--- /dev/null
+++ b/backend/internal/tui/app.go
@@ -0,0 +1,244 @@
+package tui
+
+import (
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type Model struct {
+ version string
+ state ApplicationState
+
+ osInfo *distros.OSInfo
+ dependencies []deps.Dependency
+ err error
+
+ spinner spinner.Model
+ passwordInput textinput.Model
+ width int
+ height int
+ isLoading bool
+ styles Styles
+
+ logMessages []string
+ logChan chan string
+ logFilePath string
+ packageProgressChan chan packageInstallProgressMsg
+ packageProgress packageInstallProgressMsg
+ installationLogs []string
+ showDebugLogs bool
+
+ selectedWM int
+ selectedTerminal int
+ selectedDep int
+ selectedConfig int
+ reinstallItems map[string]bool
+ disabledItems map[string]bool
+ replaceConfigs map[string]bool
+ skipGentooUseFlags bool
+ sudoPassword string
+ existingConfigs []ExistingConfigInfo
+ fingerprintFailed bool
+}
+
+func NewModel(version string, logFilePath string) Model {
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+
+ theme := TerminalTheme()
+ styles := NewStyles(theme)
+ s.Style = styles.SpinnerStyle
+
+ pi := textinput.New()
+ pi.Placeholder = "Enter sudo password"
+ pi.EchoMode = textinput.EchoPassword
+ pi.EchoCharacter = '•'
+ pi.Focus()
+
+ logChan := make(chan string, 1000)
+ packageProgressChan := make(chan packageInstallProgressMsg, 100)
+
+ return Model{
+ version: version,
+ state: StateWelcome,
+ spinner: s,
+ passwordInput: pi,
+ isLoading: true,
+ styles: styles,
+
+ logMessages: []string{},
+ logChan: logChan,
+ logFilePath: logFilePath,
+ packageProgressChan: packageProgressChan,
+ packageProgress: packageInstallProgressMsg{
+ progress: 0.0,
+ step: "Initializing package installation",
+ isComplete: false,
+ },
+ showDebugLogs: false,
+ selectedWM: 0,
+ selectedTerminal: 0, // Default to Ghostty
+ selectedDep: 0,
+ selectedConfig: 0,
+ reinstallItems: make(map[string]bool),
+ disabledItems: make(map[string]bool),
+ replaceConfigs: make(map[string]bool),
+ installationLogs: []string{},
+ }
+}
+
+func (m Model) GetLogChan() <-chan string {
+ return m.logChan
+}
+
+func (m Model) Init() tea.Cmd {
+ return tea.Batch(
+ m.spinner.Tick,
+ m.listenForLogs(),
+ m.detectOS(),
+ )
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "ctrl+c":
+ return m, tea.Quit
+ case "ctrl+d":
+ // Toggle debug logs view (except during password input states)
+ if m.state != StatePasswordPrompt && m.state != StateFingerprintAuth {
+ m.showDebugLogs = !m.showDebugLogs
+ return m, nil
+ }
+ }
+ }
+
+ if tickMsg, ok := msg.(spinner.TickMsg); ok {
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(tickMsg)
+ return m, tea.Batch(cmd, m.listenForLogs())
+ }
+
+ if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
+ m.width = sizeMsg.Width
+ m.height = sizeMsg.Height
+ }
+
+ if logMsg, ok := msg.(logMsg); ok {
+ m.logMessages = append(m.logMessages, logMsg.message)
+ return m, m.listenForLogs()
+ }
+
+ switch m.state {
+ case StateWelcome:
+ return m.updateWelcomeState(msg)
+ case StateSelectWindowManager:
+ return m.updateSelectWindowManagerState(msg)
+ case StateSelectTerminal:
+ return m.updateSelectTerminalState(msg)
+ case StateMissingWMInstructions:
+ return m.updateMissingWMInstructionsState(msg)
+ case StateDetectingDeps:
+ return m.updateDetectingDepsState(msg)
+ case StateDependencyReview:
+ return m.updateDependencyReviewState(msg)
+ case StateGentooUseFlags:
+ return m.updateGentooUseFlagsState(msg)
+ case StateGentooGCCCheck:
+ return m.updateGentooGCCCheckState(msg)
+ case StateAuthMethodChoice:
+ return m.updateAuthMethodChoiceState(msg)
+ case StateFingerprintAuth:
+ return m.updateFingerprintAuthState(msg)
+ case StatePasswordPrompt:
+ return m.updatePasswordPromptState(msg)
+ case StateInstallingPackages:
+ return m.updateInstallingPackagesState(msg)
+ case StateConfigConfirmation:
+ return m.updateConfigConfirmationState(msg)
+ case StateDeployingConfigs:
+ return m.updateDeployingConfigsState(msg)
+ case StateInstallComplete:
+ return m.updateInstallCompleteState(msg)
+ case StateError:
+ return m.updateErrorState(msg)
+ default:
+ return m, m.listenForLogs()
+ }
+}
+
+func (m Model) View() string {
+ // If debug logs are shown, show that view regardless of state
+ if m.showDebugLogs {
+ return m.viewDebugLogs()
+ }
+
+ switch m.state {
+ case StateWelcome:
+ return m.viewWelcome()
+ case StateSelectWindowManager:
+ return m.viewSelectWindowManager()
+ case StateSelectTerminal:
+ return m.viewSelectTerminal()
+ case StateMissingWMInstructions:
+ return m.viewMissingWMInstructions()
+ case StateDetectingDeps:
+ return m.viewDetectingDeps()
+ case StateDependencyReview:
+ return m.viewDependencyReview()
+ case StateGentooUseFlags:
+ return m.viewGentooUseFlags()
+ case StateGentooGCCCheck:
+ return m.viewGentooGCCCheck()
+ case StateAuthMethodChoice:
+ return m.viewAuthMethodChoice()
+ case StateFingerprintAuth:
+ return m.viewFingerprintAuth()
+ case StatePasswordPrompt:
+ return m.viewPasswordPrompt()
+ case StateInstallingPackages:
+ return m.viewInstallingPackages()
+ case StateConfigConfirmation:
+ return m.viewConfigConfirmation()
+ case StateDeployingConfigs:
+ return m.viewDeployingConfigs()
+ case StateInstallComplete:
+ return m.viewInstallComplete()
+ case StateError:
+ return m.viewError()
+ default:
+ return m.viewWelcome()
+ }
+}
+
+func (m Model) listenForLogs() tea.Cmd {
+ return func() tea.Msg {
+ select {
+ case msg, ok := <-m.logChan:
+ if !ok {
+ return nil
+ }
+ return logMsg{message: msg}
+ default:
+ return nil
+ }
+ }
+}
+
+func (m Model) detectOS() tea.Cmd {
+ return func() tea.Msg {
+ info, err := distros.GetOSInfo()
+ osInfoMsg := &distros.OSInfo{}
+ if info != nil {
+ osInfoMsg.Distribution = info.Distribution
+ osInfoMsg.Version = info.Version
+ osInfoMsg.VersionID = info.VersionID
+ osInfoMsg.PrettyName = info.PrettyName
+ osInfoMsg.Architecture = info.Architecture
+ }
+ return osInfoCompleteMsg{info: osInfoMsg, err: err}
+ }
+}
diff --git a/backend/internal/tui/banner.go b/backend/internal/tui/banner.go
new file mode 100644
index 00000000..d0f625be
--- /dev/null
+++ b/backend/internal/tui/banner.go
@@ -0,0 +1,21 @@
+package tui
+
+import "github.com/charmbracelet/lipgloss"
+
+func (m Model) renderBanner() string {
+ logo := `
+██████╗ █████╗ ███╗ ██╗██╗ ██╗
+██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
+██║ ██║███████║██╔██╗ ██║█████╔╝
+██║ ██║██╔══██║██║╚██╗██║██╔═██╗
+██████╔╝██║ ██║██║ ╚████║██║ ██╗
+╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ `
+
+ theme := TerminalTheme()
+ style := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)).
+ Bold(true).
+ MarginBottom(1)
+
+ return style.Render(logo)
+}
diff --git a/backend/internal/tui/messages.go b/backend/internal/tui/messages.go
new file mode 100644
index 00000000..6de94e54
--- /dev/null
+++ b/backend/internal/tui/messages.go
@@ -0,0 +1,39 @@
+package tui
+
+import (
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
+)
+
+type logMsg struct {
+ message string
+}
+
+type osInfoCompleteMsg struct {
+ info *distros.OSInfo
+ err error
+}
+
+type depsDetectedMsg struct {
+ deps []deps.Dependency
+ err error
+}
+
+type packageInstallProgressMsg struct {
+ progress float64
+ step string
+ isComplete bool
+ needsSudo bool
+ commandInfo string
+ logOutput string
+ error error
+}
+
+type packageProgressCompletedMsg struct{}
+
+type passwordValidMsg struct {
+ password string
+ valid bool
+}
+
+type delayCompleteMsg struct{}
diff --git a/backend/internal/tui/states.go b/backend/internal/tui/states.go
new file mode 100644
index 00000000..f3e660ec
--- /dev/null
+++ b/backend/internal/tui/states.go
@@ -0,0 +1,23 @@
+package tui
+
+type ApplicationState int
+
+const (
+ StateWelcome ApplicationState = iota
+ StateSelectWindowManager
+ StateSelectTerminal
+ StateMissingWMInstructions
+ StateDetectingDeps
+ StateDependencyReview
+ StateGentooUseFlags
+ StateGentooGCCCheck
+ StateAuthMethodChoice
+ StateFingerprintAuth
+ StatePasswordPrompt
+ StateInstallingPackages
+ StateConfigConfirmation
+ StateDeployingConfigs
+ StateInstallComplete
+ StateFinalComplete
+ StateError
+)
diff --git a/backend/internal/tui/styles.go b/backend/internal/tui/styles.go
new file mode 100644
index 00000000..45a9dae6
--- /dev/null
+++ b/backend/internal/tui/styles.go
@@ -0,0 +1,124 @@
+package tui
+
+import (
+ "github.com/charmbracelet/bubbles/progress"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type AppTheme struct {
+ Primary string
+ Secondary string
+ Accent string
+ Text string
+ Subtle string
+ Error string
+ Warning string
+ Success string
+ Background string
+ Surface string
+}
+
+func TerminalTheme() AppTheme {
+ return AppTheme{
+ Primary: "6", // #625690 - purple
+ Secondary: "5", // #36247a - dark purple
+ Accent: "12", // #7060ac - light purple
+ Text: "7", // #2e2e2e - dark gray
+ Subtle: "8", // #4a4a4a - medium gray
+ Error: "1", // #d83636 - red
+ Warning: "3", // #ffff89 - yellow
+ Success: "2", // #53e550 - green
+ Background: "15", // #1a1a1a - near black
+ Surface: "8", // #4a4a4a - medium gray
+ }
+}
+
+func NewStyles(theme AppTheme) Styles {
+ return Styles{
+ Title: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)).
+ Bold(true).
+ MarginLeft(1).
+ MarginBottom(1),
+
+ Normal: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Text)),
+
+ Bold: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Text)).
+ Bold(true),
+
+ Subtle: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Subtle)),
+
+ Error: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Error)),
+
+ Warning: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Warning)),
+
+ StatusBar: lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#33275e")).
+ Background(lipgloss.Color(theme.Primary)).
+ Padding(0, 1),
+
+ Key: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Accent)).
+ Bold(true),
+
+ SpinnerStyle: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)),
+
+ Success: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Success)).
+ Bold(true),
+
+ HighlightButton: lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#33275e")).
+ Background(lipgloss.Color(theme.Primary)).
+ Padding(0, 2).
+ Bold(true),
+
+ SelectedOption: lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Accent)).
+ Bold(true),
+
+ CodeBlock: lipgloss.NewStyle().
+ Background(lipgloss.Color(theme.Surface)).
+ Foreground(lipgloss.Color(theme.Text)).
+ Padding(1, 2).
+ MarginLeft(2),
+ }
+}
+
+type Styles struct {
+ Title lipgloss.Style
+ Normal lipgloss.Style
+ Bold lipgloss.Style
+ Subtle lipgloss.Style
+ Warning lipgloss.Style
+ Error lipgloss.Style
+ StatusBar lipgloss.Style
+ Key lipgloss.Style
+ SpinnerStyle lipgloss.Style
+ Success lipgloss.Style
+ HighlightButton lipgloss.Style
+ SelectedOption lipgloss.Style
+ CodeBlock lipgloss.Style
+}
+
+func (s Styles) NewThemedProgress(width int) progress.Model {
+ theme := TerminalTheme()
+ prog := progress.New(
+ progress.WithGradient(theme.Secondary, theme.Primary),
+ )
+
+ prog.Width = width
+ prog.ShowPercentage = true
+ prog.PercentFormat = "%.0f%%"
+ prog.PercentageStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Text)).
+ Bold(true)
+
+ return prog
+}
diff --git a/backend/internal/tui/views_config.go b/backend/internal/tui/views_config.go
new file mode 100644
index 00000000..f916f696
--- /dev/null
+++ b/backend/internal/tui/views_config.go
@@ -0,0 +1,382 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/config"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type configDeploymentResult struct {
+ results []config.DeploymentResult
+ error error
+}
+
+type ExistingConfigInfo struct {
+ ConfigType string
+ Path string
+ Exists bool
+}
+
+type configCheckResult struct {
+ configs []ExistingConfigInfo
+ error error
+}
+
+func (m Model) viewDeployingConfigs() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Deploying Configurations")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ spinner := m.spinner.View()
+ status := m.styles.Normal.Render("Setting up configuration files...")
+ b.WriteString(fmt.Sprintf("%s %s", spinner, status))
+ b.WriteString("\n\n")
+
+ // Show progress information
+ info := m.styles.Subtle.Render("• Creating backups of existing configurations\n• Deploying optimized configurations\n• Detecting system paths")
+ b.WriteString(info)
+
+ // Show live log output if available
+ if len(m.installationLogs) > 0 {
+ b.WriteString("\n\n")
+ logHeader := m.styles.Subtle.Render("Configuration Log:")
+ b.WriteString(logHeader)
+ b.WriteString("\n")
+
+ // Show last few lines of logs
+ maxLines := 5
+ startIdx := 0
+ if len(m.installationLogs) > maxLines {
+ startIdx = len(m.installationLogs) - maxLines
+ }
+
+ for i := startIdx; i < len(m.installationLogs); i++ {
+ if m.installationLogs[i] != "" {
+ logLine := m.styles.Subtle.Render(" " + m.installationLogs[i])
+ b.WriteString(logLine)
+ b.WriteString("\n")
+ }
+ }
+ }
+
+ return b.String()
+}
+
+func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if result, ok := msg.(configDeploymentResult); ok {
+ if result.error != nil {
+ m.err = result.error
+ m.state = StateError
+ m.isLoading = false
+ return m, nil
+ }
+
+ for _, deployResult := range result.results {
+ if deployResult.Deployed {
+ logMsg := fmt.Sprintf("✓ %s configuration deployed", deployResult.ConfigType)
+ if deployResult.BackupPath != "" {
+ logMsg += fmt.Sprintf(" (backup: %s)", deployResult.BackupPath)
+ }
+ m.installationLogs = append(m.installationLogs, logMsg)
+ }
+ }
+
+ m.state = StateInstallComplete
+ m.isLoading = false
+ return m, nil
+ }
+
+ return m, m.listenForLogs()
+}
+
+func (m Model) deployConfigurations() tea.Cmd {
+ return func() tea.Msg {
+ // Determine the selected window manager
+ var wm deps.WindowManager
+ switch m.selectedWM {
+ case 0:
+ wm = deps.WindowManagerNiri
+ case 1:
+ wm = deps.WindowManagerHyprland
+ default:
+ wm = deps.WindowManagerNiri
+ }
+
+ // Determine the selected terminal
+ var terminal deps.Terminal
+ if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
+ switch m.selectedTerminal {
+ case 0:
+ terminal = deps.TerminalKitty
+ case 1:
+ terminal = deps.TerminalAlacritty
+ default:
+ terminal = deps.TerminalKitty
+ }
+ } else {
+ switch m.selectedTerminal {
+ case 0:
+ terminal = deps.TerminalGhostty
+ case 1:
+ terminal = deps.TerminalKitty
+ default:
+ terminal = deps.TerminalAlacritty
+ }
+ }
+
+ deployer := config.NewConfigDeployer(m.logChan)
+
+ results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(context.Background(), wm, terminal, m.dependencies, m.replaceConfigs, m.reinstallItems)
+
+ return configDeploymentResult{
+ results: results,
+ error: err,
+ }
+ }
+}
+
+func (m Model) viewConfigConfirmation() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Configuration Deployment")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ if len(m.existingConfigs) == 0 {
+ // No existing configs, proceed directly
+ info := m.styles.Normal.Render("No existing configurations found. Proceeding with deployment...")
+ b.WriteString(info)
+ return b.String()
+ }
+
+ // Show existing configurations with toggle options
+ for i, configInfo := range m.existingConfigs {
+ if configInfo.Exists {
+ var status string
+ var replaceMarker string
+
+ shouldReplace := m.replaceConfigs[configInfo.ConfigType]
+ if _, exists := m.replaceConfigs[configInfo.ConfigType]; !exists {
+ shouldReplace = true
+ m.replaceConfigs[configInfo.ConfigType] = true
+ }
+
+ if shouldReplace {
+ replaceMarker = "🔄 "
+ status = m.styles.Warning.Render("Will replace")
+ } else {
+ replaceMarker = "✓ "
+ status = m.styles.Success.Render("Keep existing")
+ }
+
+ var line string
+ if i == m.selectedConfig {
+ line = fmt.Sprintf("▶ %s%-15s %s", replaceMarker, configInfo.ConfigType, status)
+ line += fmt.Sprintf("\n %s", configInfo.Path)
+ line = m.styles.SelectedOption.Render(line)
+ } else {
+ line = fmt.Sprintf(" %s%-15s %s", replaceMarker, configInfo.ConfigType, status)
+ line += fmt.Sprintf("\n %s", configInfo.Path)
+ line = m.styles.Normal.Render(line)
+ }
+
+ b.WriteString(line)
+ b.WriteString("\n\n")
+ }
+ }
+
+ backup := m.styles.Success.Render("✓ Replaced configurations will be backed up with timestamp")
+ b.WriteString(backup)
+ b.WriteString("\n\n")
+
+ help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle replace/keep, Enter: Continue")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateConfigConfirmationState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if result, ok := msg.(configCheckResult); ok {
+ if result.error != nil {
+ m.err = result.error
+ m.state = StateError
+ return m, nil
+ }
+
+ m.existingConfigs = result.configs
+
+ firstExistingSet := false
+ for i, config := range result.configs {
+ if config.Exists {
+ m.replaceConfigs[config.ConfigType] = true
+ if !firstExistingSet {
+ m.selectedConfig = i
+ firstExistingSet = true
+ }
+ }
+ }
+
+ hasExisting := false
+ for _, config := range result.configs {
+ if config.Exists {
+ hasExisting = true
+ break
+ }
+ }
+
+ if !hasExisting {
+ // No existing configs, proceed directly to deployment
+ m.state = StateDeployingConfigs
+ return m, m.deployConfigurations()
+ }
+
+ // Show confirmation view
+ return m, nil
+ }
+
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "up":
+ if m.selectedConfig > 0 {
+ for i := m.selectedConfig - 1; i >= 0; i-- {
+ if m.existingConfigs[i].Exists {
+ m.selectedConfig = i
+ break
+ }
+ }
+ }
+ case "down":
+ if m.selectedConfig < len(m.existingConfigs)-1 {
+ for i := m.selectedConfig + 1; i < len(m.existingConfigs); i++ {
+ if m.existingConfigs[i].Exists {
+ m.selectedConfig = i
+ break
+ }
+ }
+ }
+ case " ":
+ if len(m.existingConfigs) > 0 && m.selectedConfig < len(m.existingConfigs) {
+ configType := m.existingConfigs[m.selectedConfig].ConfigType
+ if m.existingConfigs[m.selectedConfig].Exists {
+ m.replaceConfigs[configType] = !m.replaceConfigs[configType]
+ }
+ }
+ case "enter":
+ m.state = StateDeployingConfigs
+ return m, m.deployConfigurations()
+ }
+ }
+
+ return m, nil
+}
+
+func (m Model) checkExistingConfigurations() tea.Cmd {
+ return func() tea.Msg {
+ var configs []ExistingConfigInfo
+
+ if m.selectedWM == 0 {
+ niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
+ niriExists := false
+ if _, err := os.Stat(niriPath); err == nil {
+ niriExists = true
+ }
+ configs = append(configs, ExistingConfigInfo{
+ ConfigType: "Niri",
+ Path: niriPath,
+ Exists: niriExists,
+ })
+ } else {
+ hyprlandPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
+ hyprlandExists := false
+ if _, err := os.Stat(hyprlandPath); err == nil {
+ hyprlandExists = true
+ }
+ configs = append(configs, ExistingConfigInfo{
+ ConfigType: "Hyprland",
+ Path: hyprlandPath,
+ Exists: hyprlandExists,
+ })
+ }
+
+ if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
+ if m.selectedTerminal == 0 {
+ kittyPath := filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf")
+ kittyExists := false
+ if _, err := os.Stat(kittyPath); err == nil {
+ kittyExists = true
+ }
+ configs = append(configs, ExistingConfigInfo{
+ ConfigType: "Kitty",
+ Path: kittyPath,
+ Exists: kittyExists,
+ })
+ } else {
+ alacrittyPath := filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml")
+ alacrittyExists := false
+ if _, err := os.Stat(alacrittyPath); err == nil {
+ alacrittyExists = true
+ }
+ configs = append(configs, ExistingConfigInfo{
+ ConfigType: "Alacritty",
+ Path: alacrittyPath,
+ Exists: alacrittyExists,
+ })
+ }
+ } else {
+ switch m.selectedTerminal {
+ case 0:
+ ghosttyPath := filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config")
+ ghosttyExists := false
+ if _, err := os.Stat(ghosttyPath); err == nil {
+ ghosttyExists = true
+ }
+ configs = append(configs, ExistingConfigInfo{
+ ConfigType: "Ghostty",
+ Path: ghosttyPath,
+ Exists: ghosttyExists,
+ })
+ case 1:
+ kittyPath := filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf")
+ kittyExists := false
+ if _, err := os.Stat(kittyPath); err == nil {
+ kittyExists = true
+ }
+ configs = append(configs, ExistingConfigInfo{
+ ConfigType: "Kitty",
+ Path: kittyPath,
+ Exists: kittyExists,
+ })
+ default:
+ alacrittyPath := filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml")
+ alacrittyExists := false
+ if _, err := os.Stat(alacrittyPath); err == nil {
+ alacrittyExists = true
+ }
+ configs = append(configs, ExistingConfigInfo{
+ ConfigType: "Alacritty",
+ Path: alacrittyPath,
+ Exists: alacrittyExists,
+ })
+ }
+ }
+
+ return configCheckResult{
+ configs: configs,
+ error: nil,
+ }
+ }
+}
diff --git a/backend/internal/tui/views_dependencies.go b/backend/internal/tui/views_dependencies.go
new file mode 100644
index 00000000..6ccd6439
--- /dev/null
+++ b/backend/internal/tui/views_dependencies.go
@@ -0,0 +1,256 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m Model) viewDetectingDeps() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Detecting Dependencies")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ spinner := m.spinner.View()
+ status := m.styles.Normal.Render("Scanning system for existing packages and configurations...")
+ b.WriteString(fmt.Sprintf("%s %s", spinner, status))
+
+ return b.String()
+}
+
+func (m Model) viewDependencyReview() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Dependency Review")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ if len(m.dependencies) > 0 {
+ for i, dep := range m.dependencies {
+ var status string
+ var reinstallMarker string
+ var variantMarker string
+
+ isDMS := dep.Name == "dms (DankMaterialShell)"
+
+ if dep.CanToggle && dep.Variant == deps.VariantGit {
+ variantMarker = "[git] "
+ }
+
+ if m.disabledItems[dep.Name] {
+ reinstallMarker = "✗ "
+ status = m.styles.Subtle.Render("Will skip")
+ } else if m.reinstallItems[dep.Name] {
+ reinstallMarker = "🔄 "
+ status = m.styles.Warning.Render("Will upgrade")
+ } else if isDMS {
+ reinstallMarker = "⚡ "
+ switch dep.Status {
+ case deps.StatusInstalled:
+ status = m.styles.Success.Render("✓ Required (installed)")
+ case deps.StatusMissing:
+ status = m.styles.Warning.Render("○ Required (will install)")
+ case deps.StatusNeedsUpdate:
+ status = m.styles.Warning.Render("△ Required (needs update)")
+ case deps.StatusNeedsReinstall:
+ status = m.styles.Error.Render("! Required (needs reinstall)")
+ }
+ } else {
+ switch dep.Status {
+ case deps.StatusInstalled:
+ status = m.styles.Subtle.Render("✓ Already installed")
+ case deps.StatusMissing:
+ status = m.styles.Warning.Render("○ Will install")
+ case deps.StatusNeedsUpdate:
+ status = m.styles.Warning.Render("△ Will install")
+ case deps.StatusNeedsReinstall:
+ status = m.styles.Error.Render("! Will install")
+ }
+ }
+
+ var line string
+ if i == m.selectedDep {
+ line = fmt.Sprintf("▶ %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
+ if dep.Version != "" {
+ line += fmt.Sprintf(" (%s)", dep.Version)
+ }
+ line = m.styles.SelectedOption.Render(line)
+ } else {
+ line = fmt.Sprintf(" %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status)
+ if dep.Version != "" {
+ line += fmt.Sprintf(" (%s)", dep.Version)
+ }
+ line = m.styles.Normal.Render(line)
+ }
+
+ b.WriteString(line)
+ b.WriteString("\n")
+ }
+ }
+
+ b.WriteString("\n")
+ help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle, G: Toggle stable/git, Enter: Continue")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if depsMsg, ok := msg.(depsDetectedMsg); ok {
+ m.isLoading = false
+ if depsMsg.err != nil {
+ m.err = depsMsg.err
+ m.state = StateError
+ } else {
+ m.dependencies = depsMsg.deps
+ m.state = StateDependencyReview
+ }
+ return m, m.listenForLogs()
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "up":
+ if m.selectedDep > 0 {
+ m.selectedDep--
+ }
+ case "down":
+ if m.selectedDep < len(m.dependencies)-1 {
+ m.selectedDep++
+ }
+ case " ":
+ if len(m.dependencies) > 0 {
+ depName := m.dependencies[m.selectedDep].Name
+ isDMS := depName == "dms (DankMaterialShell)"
+
+ if !isDMS {
+ isInstalled := m.dependencies[m.selectedDep].Status == deps.StatusInstalled ||
+ m.dependencies[m.selectedDep].Status == deps.StatusNeedsReinstall
+
+ if isInstalled {
+ m.reinstallItems[depName] = !m.reinstallItems[depName]
+ m.disabledItems[depName] = false
+ } else {
+ m.disabledItems[depName] = !m.disabledItems[depName]
+ m.reinstallItems[depName] = false
+ }
+ }
+ }
+ case "g", "G":
+ if len(m.dependencies) > 0 && m.dependencies[m.selectedDep].CanToggle {
+ if m.dependencies[m.selectedDep].Variant == deps.VariantStable {
+ m.dependencies[m.selectedDep].Variant = deps.VariantGit
+ } else {
+ m.dependencies[m.selectedDep].Variant = deps.VariantStable
+ }
+ }
+ case "enter":
+ // Check if on Gentoo - show USE flags screen
+ if m.osInfo != nil {
+ if config, exists := distros.Registry[m.osInfo.Distribution.ID]; exists && config.Family == distros.FamilyGentoo {
+ m.state = StateGentooUseFlags
+ return m, nil
+ }
+ }
+ // Check if fingerprint is enabled
+ if checkFingerprintEnabled() {
+ m.state = StateAuthMethodChoice
+ m.selectedConfig = 0 // Default to fingerprint
+ return m, nil
+ } else {
+ m.state = StatePasswordPrompt
+ m.passwordInput.Focus()
+ return m, nil
+ }
+ case "esc":
+ m.state = StateSelectWindowManager
+ return m, nil
+ }
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) installPackages() tea.Cmd {
+ return func() tea.Msg {
+ if m.osInfo == nil {
+ return packageInstallProgressMsg{
+ progress: 0.0,
+ step: "Error: OS info not available",
+ isComplete: true,
+ }
+ }
+
+ installer, err := distros.NewPackageInstaller(m.osInfo.Distribution.ID, m.logChan)
+ if err != nil {
+ return packageInstallProgressMsg{
+ progress: 0.0,
+ step: fmt.Sprintf("Error: %s", err.Error()),
+ isComplete: true,
+ }
+ }
+
+ // Convert TUI selection to deps enum
+ var wm deps.WindowManager
+ if m.selectedWM == 0 {
+ wm = deps.WindowManagerNiri
+ } else {
+ wm = deps.WindowManagerHyprland
+ }
+
+ installerProgressChan := make(chan distros.InstallProgressMsg, 100)
+
+ go func() {
+ defer close(installerProgressChan)
+ err := installer.InstallPackages(context.Background(), m.dependencies, wm, m.sudoPassword, m.reinstallItems, m.disabledItems, m.skipGentooUseFlags, installerProgressChan)
+ if err != nil {
+ installerProgressChan <- distros.InstallProgressMsg{
+ Progress: 0.0,
+ Step: fmt.Sprintf("Installation error: %s", err.Error()),
+ IsComplete: true,
+ Error: err,
+ }
+ }
+ }()
+
+ // Convert installer messages to TUI messages
+ go func() {
+ for msg := range installerProgressChan {
+ tuiMsg := packageInstallProgressMsg{
+ progress: msg.Progress,
+ step: msg.Step,
+ isComplete: msg.IsComplete,
+ needsSudo: msg.NeedsSudo,
+ commandInfo: msg.CommandInfo,
+ logOutput: msg.LogOutput,
+ error: msg.Error,
+ }
+ if msg.IsComplete {
+ m.logChan <- fmt.Sprintf("[DEBUG] Sending completion signal: step=%s, progress=%.2f", msg.Step, msg.Progress)
+ }
+ m.packageProgressChan <- tuiMsg
+ }
+ m.logChan <- "[DEBUG] Installer channel closed"
+ }()
+
+ return packageInstallProgressMsg{
+ progress: 0.05,
+ step: "Starting installation...",
+ isComplete: false,
+ }
+ }
+}
diff --git a/backend/internal/tui/views_gentoo_gcc.go b/backend/internal/tui/views_gentoo_gcc.go
new file mode 100644
index 00000000..74195a44
--- /dev/null
+++ b/backend/internal/tui/views_gentoo_gcc.go
@@ -0,0 +1,103 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "strconv"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type gccVersionCheckMsg struct {
+ version string
+ major int
+ err error
+}
+
+func (m Model) viewGentooGCCCheck() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("GCC Version Check Failed")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ error := m.styles.Error.Render("⚠ Hyprland requires GCC 15 or newer")
+ b.WriteString(error)
+ b.WriteString("\n\n")
+
+ info := m.styles.Normal.Render("Your current GCC version is too old. Please upgrade GCC before continuing.")
+ b.WriteString(info)
+ b.WriteString("\n\n")
+
+ instructionsTitle := m.styles.Subtle.Render("To upgrade GCC:")
+ b.WriteString(instructionsTitle)
+ b.WriteString("\n\n")
+
+ steps := []string{
+ "1. Install GCC 15 (if not already installed):",
+ " sudo emerge -1av =sys-devel/gcc:15",
+ "",
+ "2. Switch to GCC 15 using gcc-config:",
+ " sudo gcc-config $(gcc-config -l | grep gcc-15 | awk '{print $2}' | head -1)",
+ "",
+ "3. Update environment:",
+ " source /etc/profile",
+ "",
+ "4. Restart this installer",
+ }
+
+ for _, step := range steps {
+ stepLine := m.styles.Subtle.Render(step)
+ b.WriteString(stepLine)
+ b.WriteString("\n")
+ }
+
+ b.WriteString("\n")
+ help := m.styles.Subtle.Render("Press Esc to go back, Ctrl+C to exit")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateGentooGCCCheckState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "esc":
+ m.state = StateSelectWindowManager
+ return m, nil
+ }
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) checkGCCVersion() tea.Cmd {
+ return func() tea.Msg {
+ cmd := exec.CommandContext(context.Background(), "gcc", "-dumpversion")
+ output, err := cmd.Output()
+ if err != nil {
+ return gccVersionCheckMsg{err: err}
+ }
+
+ version := strings.TrimSpace(string(output))
+ parts := strings.Split(version, ".")
+ if len(parts) == 0 {
+ return gccVersionCheckMsg{err: fmt.Errorf("invalid gcc version format")}
+ }
+
+ major, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return gccVersionCheckMsg{err: err}
+ }
+
+ return gccVersionCheckMsg{
+ version: version,
+ major: major,
+ err: nil,
+ }
+ }
+}
diff --git a/backend/internal/tui/views_gentoo_use_flags.go b/backend/internal/tui/views_gentoo_use_flags.go
new file mode 100644
index 00000000..13c5528e
--- /dev/null
+++ b/backend/internal/tui/views_gentoo_use_flags.go
@@ -0,0 +1,92 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m Model) viewGentooUseFlags() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Gentoo Global USE Flags")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ info := m.styles.Normal.Render("The following global USE flags will be enabled in /etc/portage/make.conf:")
+ b.WriteString(info)
+ b.WriteString("\n\n")
+
+ for _, flag := range distros.GentooGlobalUseFlags {
+ flagLine := m.styles.Success.Render(fmt.Sprintf(" • %s", flag))
+ b.WriteString(flagLine)
+ b.WriteString("\n")
+ }
+
+ b.WriteString("\n")
+ note := m.styles.Subtle.Render("These flags ensure proper Qt6, Wayland, and compositor support.")
+ b.WriteString(note)
+ b.WriteString("\n\n")
+
+ var toggleLine string
+ if m.skipGentooUseFlags {
+ toggleLine = "▶ [✗] Skip adding global USE flags (will use existing configuration)"
+ toggleLine = m.styles.Warning.Render(toggleLine)
+ } else {
+ toggleLine = " [ ] Skip adding global USE flags (will use existing configuration)"
+ toggleLine = m.styles.Subtle.Render(toggleLine)
+ }
+ b.WriteString(toggleLine)
+ b.WriteString("\n\n")
+
+ help := m.styles.Subtle.Render("Space: Toggle skip, Enter: Continue, Esc: Go back")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if gccMsg, ok := msg.(gccVersionCheckMsg); ok {
+ if gccMsg.err != nil || gccMsg.major < 15 {
+ m.state = StateGentooGCCCheck
+ return m, nil
+ }
+ if checkFingerprintEnabled() {
+ m.state = StateAuthMethodChoice
+ m.selectedConfig = 0
+ } else {
+ m.state = StatePasswordPrompt
+ m.passwordInput.Focus()
+ }
+ return m, nil
+ }
+
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case " ":
+ m.skipGentooUseFlags = !m.skipGentooUseFlags
+ return m, nil
+ case "enter":
+ if m.selectedWM == 1 {
+ return m, m.checkGCCVersion()
+ }
+ if checkFingerprintEnabled() {
+ m.state = StateAuthMethodChoice
+ m.selectedConfig = 0
+ } else {
+ m.state = StatePasswordPrompt
+ m.passwordInput.Focus()
+ }
+ return m, nil
+ case "esc":
+ m.state = StateDependencyReview
+ return m, nil
+ }
+ }
+ return m, m.listenForLogs()
+}
diff --git a/backend/internal/tui/views_install.go b/backend/internal/tui/views_install.go
new file mode 100644
index 00000000..69640dab
--- /dev/null
+++ b/backend/internal/tui/views_install.go
@@ -0,0 +1,333 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// wrapText wraps text to the specified width
+func wrapText(text string, width int) string {
+ if len(text) <= width {
+ return text
+ }
+
+ var result strings.Builder
+ words := strings.Fields(text)
+ currentLine := ""
+
+ for _, word := range words {
+ if len(currentLine) == 0 {
+ currentLine = word
+ } else if len(currentLine)+1+len(word) <= width {
+ currentLine += " " + word
+ } else {
+ result.WriteString(currentLine)
+ result.WriteString("\n")
+ currentLine = word
+ }
+ }
+
+ if len(currentLine) > 0 {
+ result.WriteString(currentLine)
+ }
+
+ return result.String()
+}
+
+func (m Model) viewInstallingPackages() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Installing Packages")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ if !m.packageProgress.isComplete {
+ spinner := m.spinner.View()
+ status := m.styles.Normal.Render(m.packageProgress.step)
+ b.WriteString(fmt.Sprintf("%s %s", spinner, status))
+ b.WriteString("\n\n")
+
+ // Show progress bar
+ progressBar := fmt.Sprintf("[%s%s] %.0f%%",
+ strings.Repeat("█", int(m.packageProgress.progress*30)),
+ strings.Repeat("░", 30-int(m.packageProgress.progress*30)),
+ m.packageProgress.progress*100)
+ b.WriteString(m.styles.Normal.Render(progressBar))
+ b.WriteString("\n")
+
+ // Show command info if available
+ if m.packageProgress.commandInfo != "" {
+ cmdInfo := m.styles.Subtle.Render("$ " + m.packageProgress.commandInfo)
+ b.WriteString(cmdInfo)
+ b.WriteString("\n")
+ }
+
+ // Show live log output
+ if len(m.installationLogs) > 0 {
+ b.WriteString("\n")
+ logHeader := m.styles.Subtle.Render("Live Output:")
+ b.WriteString(logHeader)
+ b.WriteString("\n")
+
+ // Show last few lines of accumulated logs
+ maxLines := 8
+ startIdx := 0
+ if len(m.installationLogs) > maxLines {
+ startIdx = len(m.installationLogs) - maxLines
+ }
+
+ for i := startIdx; i < len(m.installationLogs); i++ {
+ if m.installationLogs[i] != "" {
+ logLine := m.styles.Subtle.Render(" " + m.installationLogs[i])
+ b.WriteString(logLine)
+ b.WriteString("\n")
+ }
+ }
+ }
+
+ // Show error if any
+ if m.packageProgress.error != nil {
+ b.WriteString("\n")
+ wrappedErrorMsg := wrapText("Error: "+m.packageProgress.error.Error(), 80)
+ errorMsg := m.styles.Error.Render(wrappedErrorMsg)
+ b.WriteString(errorMsg)
+ }
+
+ // Show sudo prompt if needed
+ if m.packageProgress.needsSudo {
+ sudoWarning := m.styles.Warning.Render("⚠ Using provided sudo password")
+ b.WriteString(sudoWarning)
+ }
+ } else {
+ if m.packageProgress.error != nil {
+ wrappedFailedMsg := wrapText("✗ Installation failed: "+m.packageProgress.error.Error(), 80)
+ errorMsg := m.styles.Error.Render(wrappedFailedMsg)
+ b.WriteString(errorMsg)
+ } else {
+ success := m.styles.Success.Render("✓ Installation complete!")
+ b.WriteString(success)
+ }
+ }
+
+ return b.String()
+}
+
+func (m Model) viewInstallComplete() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Success.Render("Setup Complete!")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ success := m.styles.Success.Render("✓ All packages installed and configurations deployed.")
+ b.WriteString(success)
+ b.WriteString("\n\n")
+
+ // Show what was accomplished
+ accomplishments := []string{
+ "• Window manager and dependencies installed",
+ "• Terminal and development tools configured",
+ "• Configuration files deployed with backups",
+ "• System optimized for DankMaterialShell",
+ }
+
+ for _, item := range accomplishments {
+ b.WriteString(m.styles.Subtle.Render(item))
+ b.WriteString("\n")
+ }
+
+ b.WriteString("\n")
+ info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\" \n\nPress Enter to exit.")
+ b.WriteString(info)
+
+ if m.logFilePath != "" {
+ b.WriteString("\n\n")
+ logInfo := m.styles.Subtle.Render(fmt.Sprintf("Full logs: %s", m.logFilePath))
+ b.WriteString(logInfo)
+ }
+
+ return b.String()
+}
+
+func (m Model) viewError() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Error.Render("Installation Failed")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ if m.err != nil {
+ wrappedError := wrapText("✗ "+m.err.Error(), 80)
+ error := m.styles.Error.Render(wrappedError)
+ b.WriteString(error)
+ b.WriteString("\n\n")
+ }
+
+ // Show package progress error if available
+ if m.packageProgress.error != nil {
+ wrappedPackageError := wrapText("Package Installation Error: "+m.packageProgress.error.Error(), 80)
+ packageError := m.styles.Error.Render(wrappedPackageError)
+ b.WriteString(packageError)
+ b.WriteString("\n\n")
+ }
+
+ // Show persistent installation logs
+ if len(m.installationLogs) > 0 {
+ logHeader := m.styles.Warning.Render("Installation Logs (last 15 lines):")
+ b.WriteString(logHeader)
+ b.WriteString("\n")
+
+ maxLines := 15
+ startIdx := 0
+ if len(m.installationLogs) > maxLines {
+ startIdx = len(m.installationLogs) - maxLines
+ }
+
+ for i := startIdx; i < len(m.installationLogs); i++ {
+ if m.installationLogs[i] != "" {
+ logLine := m.styles.Subtle.Render(" " + m.installationLogs[i])
+ b.WriteString(logLine)
+ b.WriteString("\n")
+ }
+ }
+ b.WriteString("\n")
+ }
+
+ hint := m.styles.Subtle.Render("Press Ctrl+D for full debug logs")
+ b.WriteString(hint)
+ b.WriteString("\n")
+
+ if m.logFilePath != "" {
+ b.WriteString("\n")
+ logInfo := m.styles.Warning.Render(fmt.Sprintf("Full logs: %s", m.logFilePath))
+ b.WriteString(logInfo)
+ b.WriteString("\n")
+ }
+
+ help := m.styles.Subtle.Render("Press Enter to exit")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateInstallingPackagesState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if progressMsg, ok := msg.(packageInstallProgressMsg); ok {
+ m.packageProgress = progressMsg
+
+ // Accumulate log output
+ if progressMsg.logOutput != "" {
+ m.installationLogs = append(m.installationLogs, progressMsg.logOutput)
+ // Keep only last 50 lines to preserve more context for debugging
+ if len(m.installationLogs) > 50 {
+ m.installationLogs = m.installationLogs[len(m.installationLogs)-50:]
+ }
+ }
+
+ if progressMsg.isComplete {
+ if progressMsg.error != nil {
+ m.state = StateError
+ m.isLoading = false
+ } else {
+ m.installationLogs = []string{}
+ m.state = StateConfigConfirmation
+ m.isLoading = true
+ return m, tea.Batch(m.spinner.Tick, m.checkExistingConfigurations())
+ }
+ }
+ return m, m.listenForPackageProgress()
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) updateInstallCompleteState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "enter":
+ return m, tea.Quit
+ }
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) updateErrorState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "enter":
+ return m, tea.Quit
+ }
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) listenForPackageProgress() tea.Cmd {
+ return func() tea.Msg {
+ msg, ok := <-m.packageProgressChan
+ if !ok {
+ return packageProgressCompletedMsg{}
+ }
+ // Always return the message, completion will be handled in updateInstallingPackagesState
+ return msg
+ }
+}
+
+func (m Model) viewDebugLogs() string {
+ var b strings.Builder
+
+ theme := TerminalTheme()
+
+ titleStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)).
+ Bold(true)
+
+ b.WriteString(titleStyle.Render("Debug Logs"))
+ b.WriteString("\n\n")
+
+ // Combine both logMessages and installationLogs
+ allLogs := append([]string{}, m.logMessages...)
+ allLogs = append(allLogs, m.installationLogs...)
+
+ if len(allLogs) == 0 {
+ b.WriteString("No logs available\n")
+ } else {
+ // Calculate available height (reserve space for header and footer)
+ maxHeight := m.height - 6
+ if maxHeight < 10 {
+ maxHeight = 10
+ }
+
+ // Show the most recent logs
+ startIdx := 0
+ if len(allLogs) > maxHeight {
+ startIdx = len(allLogs) - maxHeight
+ }
+
+ for i := startIdx; i < len(allLogs); i++ {
+ if allLogs[i] != "" {
+ b.WriteString(fmt.Sprintf("%d: %s\n", i, allLogs[i]))
+ }
+ }
+
+ if startIdx > 0 {
+ subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
+ b.WriteString(subtleStyle.Render(fmt.Sprintf("... (%d older log entries hidden)\n", startIdx)))
+ }
+ }
+
+ b.WriteString("\n")
+ statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Accent))
+ b.WriteString(statusStyle.Render("Press Ctrl+D to return, Ctrl+C to quit"))
+
+ return b.String()
+}
diff --git a/backend/internal/tui/views_nixos_wm.go b/backend/internal/tui/views_nixos_wm.go
new file mode 100644
index 00000000..16ecebd0
--- /dev/null
+++ b/backend/internal/tui/views_nixos_wm.go
@@ -0,0 +1,85 @@
+package tui
+
+import (
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m Model) viewMissingWMInstructions() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n\n")
+
+ // Determine which WM is missing
+ wmName := "Niri"
+ installCmd := `environment.systemPackages = with pkgs; [
+ niri
+];`
+ alternateCmd := `# Or enable the module if available:
+# programs.niri.enable = true;`
+
+ if m.selectedWM == 1 {
+ wmName = "Hyprland"
+ installCmd = `programs.hyprland.enable = true;`
+ alternateCmd = `# Or add to systemPackages:
+# environment.systemPackages = with pkgs; [
+# hyprland
+# ];`
+ }
+
+ // Title
+ title := m.styles.Title.Render("⚠️ " + wmName + " Not Installed")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ // Explanation
+ explanation := m.styles.Normal.Render(wmName + " needs to be installed system-wide on NixOS.")
+ b.WriteString(explanation)
+ b.WriteString("\n\n")
+
+ // Instructions
+ instructions := m.styles.Subtle.Render("To install " + wmName + ", add this to your /etc/nixos/configuration.nix:")
+ b.WriteString(instructions)
+ b.WriteString("\n\n")
+
+ // Command box
+ cmdBox := m.styles.CodeBlock.Render(installCmd)
+ b.WriteString(cmdBox)
+ b.WriteString("\n\n")
+
+ // Alternate command
+ altBox := m.styles.Subtle.Render(alternateCmd)
+ b.WriteString(altBox)
+ b.WriteString("\n\n")
+
+ // Rebuild instruction
+ rebuildInstruction := m.styles.Normal.Render("Then rebuild your system:")
+ b.WriteString(rebuildInstruction)
+ b.WriteString("\n")
+
+ rebuildCmd := m.styles.CodeBlock.Render("sudo nixos-rebuild switch")
+ b.WriteString(rebuildCmd)
+ b.WriteString("\n\n")
+
+ // Navigation help
+ help := m.styles.Subtle.Render("Press Esc to go back and select a different window manager, or Ctrl+C to exit")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateMissingWMInstructionsState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "esc":
+ // Go back to window manager selection
+ m.state = StateSelectWindowManager
+ return m, m.listenForLogs()
+ case "ctrl+c":
+ return m, tea.Quit
+ }
+ }
+ return m, m.listenForLogs()
+}
diff --git a/backend/internal/tui/views_password.go b/backend/internal/tui/views_password.go
new file mode 100644
index 00000000..32fffc27
--- /dev/null
+++ b/backend/internal/tui/views_password.go
@@ -0,0 +1,338 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m Model) viewAuthMethodChoice() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Authentication Method")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ message := "Fingerprint authentication is available.\nHow would you like to authenticate?"
+ b.WriteString(m.styles.Normal.Render(message))
+ b.WriteString("\n\n")
+
+ // Option 0: Fingerprint
+ if m.selectedConfig == 0 {
+ option := m.styles.SelectedOption.Render("▶ Use Fingerprint")
+ b.WriteString(option)
+ } else {
+ option := m.styles.Normal.Render(" Use Fingerprint")
+ b.WriteString(option)
+ }
+ b.WriteString("\n")
+
+ // Option 1: Password
+ if m.selectedConfig == 1 {
+ option := m.styles.SelectedOption.Render("▶ Use Password")
+ b.WriteString(option)
+ } else {
+ option := m.styles.Normal.Render(" Use Password")
+ b.WriteString(option)
+ }
+ b.WriteString("\n\n")
+
+ help := m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) viewFingerprintAuth() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Fingerprint Authentication")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ if m.fingerprintFailed {
+ errorMsg := m.styles.Error.Render("✗ Fingerprint authentication failed")
+ b.WriteString(errorMsg)
+ b.WriteString("\n")
+ retryMsg := m.styles.Subtle.Render("Returning to authentication menu...")
+ b.WriteString(retryMsg)
+ } else {
+ message := "Please place your finger on the fingerprint reader."
+ b.WriteString(m.styles.Normal.Render(message))
+ b.WriteString("\n\n")
+
+ spinner := m.spinner.View()
+ status := m.styles.Normal.Render("Waiting for fingerprint...")
+ b.WriteString(fmt.Sprintf("%s %s", spinner, status))
+ }
+
+ return b.String()
+}
+
+func (m Model) viewPasswordPrompt() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Password Authentication")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ message := "Installation requires sudo privileges.\nPlease enter your password to continue:"
+ b.WriteString(m.styles.Normal.Render(message))
+ b.WriteString("\n\n")
+
+ // Password input
+ b.WriteString(m.passwordInput.View())
+ b.WriteString("\n")
+
+ // Show validation status
+ if m.packageProgress.step == "Validating sudo password..." {
+ spinner := m.spinner.View()
+ status := m.styles.Normal.Render(m.packageProgress.step)
+ b.WriteString(spinner + " " + status)
+ b.WriteString("\n")
+ } else if m.packageProgress.error != nil {
+ errorMsg := m.styles.Error.Render("✗ " + m.packageProgress.error.Error() + ". Please try again.")
+ b.WriteString(errorMsg)
+ b.WriteString("\n")
+ } else if m.packageProgress.step == "Password validation failed" {
+ errorMsg := m.styles.Error.Render("✗ Incorrect password. Please try again.")
+ b.WriteString(errorMsg)
+ b.WriteString("\n")
+ }
+
+ b.WriteString("\n")
+ help := m.styles.Subtle.Render("Enter: Continue, Esc: Back, Ctrl+C: Cancel")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateAuthMethodChoiceState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ m.fingerprintFailed = false
+
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "up":
+ if m.selectedConfig > 0 {
+ m.selectedConfig--
+ }
+ case "down":
+ if m.selectedConfig < 1 {
+ m.selectedConfig++
+ }
+ case "enter":
+ if m.selectedConfig == 0 {
+ m.state = StateFingerprintAuth
+ m.isLoading = true
+ return m, tea.Batch(m.spinner.Tick, m.tryFingerprint())
+ } else {
+ m.state = StatePasswordPrompt
+ m.passwordInput.Focus()
+ return m, nil
+ }
+ case "esc":
+ m.state = StateDependencyReview
+ return m, nil
+ }
+ }
+ return m, nil
+}
+
+func (m Model) updateFingerprintAuthState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if validMsg, ok := msg.(passwordValidMsg); ok {
+ if validMsg.valid {
+ m.sudoPassword = ""
+ m.packageProgress = packageInstallProgressMsg{}
+ m.state = StateInstallingPackages
+ m.isLoading = true
+ return m, tea.Batch(m.spinner.Tick, m.installPackages())
+ } else {
+ m.fingerprintFailed = true
+ return m, m.delayThenReturn()
+ }
+ }
+
+ if _, ok := msg.(delayCompleteMsg); ok {
+ m.fingerprintFailed = false
+ m.selectedConfig = 0
+ m.state = StateAuthMethodChoice
+ return m, nil
+ }
+
+ return m, m.listenForLogs()
+}
+
+func (m Model) updatePasswordPromptState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+
+ if validMsg, ok := msg.(passwordValidMsg); ok {
+ if validMsg.valid {
+ // Password is valid, proceed with installation
+ m.sudoPassword = validMsg.password
+ m.passwordInput.SetValue("") // Clear password input
+ // Clear any error state
+ m.packageProgress = packageInstallProgressMsg{}
+ m.state = StateInstallingPackages
+ m.isLoading = true
+ return m, tea.Batch(m.spinner.Tick, m.installPackages())
+ } else {
+ // Password is invalid, show error and stay on password prompt
+ m.packageProgress = packageInstallProgressMsg{
+ progress: 0.0,
+ step: "Password validation failed",
+ error: fmt.Errorf("incorrect password"),
+ logOutput: "Authentication failed",
+ }
+ m.passwordInput.SetValue("")
+ m.passwordInput.Focus()
+ return m, nil
+ }
+ }
+
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "enter":
+ // Don't allow multiple validation attempts while one is in progress
+ if m.packageProgress.step == "Validating sudo password..." {
+ return m, nil
+ }
+
+ // Validate password first
+ password := m.passwordInput.Value()
+ if password == "" {
+ return m, nil // Don't proceed with empty password
+ }
+
+ // Clear any previous error and show validation in progress
+ m.packageProgress = packageInstallProgressMsg{
+ progress: 0.01,
+ step: "Validating sudo password...",
+ isComplete: false,
+ logOutput: "Testing password with sudo -v",
+ }
+ return m, m.validatePassword(password)
+ case "esc":
+ // Go back to dependency review
+ m.passwordInput.SetValue("")
+ m.packageProgress = packageInstallProgressMsg{} // Clear any validation state
+ m.state = StateDependencyReview
+ return m, nil
+ }
+ }
+
+ m.passwordInput, cmd = m.passwordInput.Update(msg)
+ return m, cmd
+}
+
+func checkFingerprintEnabled() bool {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ // Check if pam_fprintd.so is in PAM config
+ cmd := exec.CommandContext(ctx, "grep", "-q", "pam_fprintd.so", "/etc/pam.d/system-auth")
+ if err := cmd.Run(); err != nil {
+ return false
+ }
+
+ // Check if fprintd-list exists and user has enrolled fingerprints
+ user := os.Getenv("USER")
+ if user == "" {
+ return false
+ }
+
+ listCmd := exec.CommandContext(ctx, "fprintd-list", user)
+ output, err := listCmd.CombinedOutput()
+ if err != nil {
+ return false
+ }
+
+ // If output contains "finger:" or similar, fingerprints are enrolled
+ return strings.Contains(string(output), "finger")
+}
+
+func (m Model) delayThenReturn() tea.Cmd {
+ return func() tea.Msg {
+ time.Sleep(2 * time.Second)
+ return delayCompleteMsg{}
+ }
+}
+
+func (m Model) tryFingerprint() tea.Cmd {
+ return func() tea.Msg {
+ clearCmd := exec.Command("sudo", "-k")
+ clearCmd.Run()
+
+ tmpDir := os.TempDir()
+ askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano()))
+
+ scriptContent := "#!/bin/sh\nexit 1\n"
+ if err := os.WriteFile(askpassScript, []byte(scriptContent), 0700); err != nil {
+ return passwordValidMsg{password: "", valid: false}
+ }
+ defer os.Remove(askpassScript)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "sudo", "-A", "-v")
+ cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript))
+
+ err := cmd.Run()
+
+ if err != nil {
+ return passwordValidMsg{password: "", valid: false}
+ }
+
+ return passwordValidMsg{password: "", valid: true}
+ }
+}
+
+func (m Model) validatePassword(password string) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return passwordValidMsg{password: "", valid: false}
+ }
+
+ if err := cmd.Start(); err != nil {
+ return passwordValidMsg{password: "", valid: false}
+ }
+
+ _, err = fmt.Fprintf(stdin, "%s\n", password)
+ stdin.Close()
+ if err != nil {
+ return passwordValidMsg{password: "", valid: false}
+ }
+
+ err = cmd.Wait()
+
+ if err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ return passwordValidMsg{password: "", valid: false}
+ }
+ return passwordValidMsg{password: "", valid: false}
+ }
+
+ return passwordValidMsg{password: password, valid: true}
+ }
+}
diff --git a/backend/internal/tui/views_selection.go b/backend/internal/tui/views_selection.go
new file mode 100644
index 00000000..cedaf537
--- /dev/null
+++ b/backend/internal/tui/views_selection.go
@@ -0,0 +1,244 @@
+package tui
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/deps"
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m Model) viewSelectWindowManager() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Choose Window Manager")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ options := []struct {
+ name string
+ description string
+ }{
+ {"niri", "Scrollable-tiling Wayland compositor."},
+ }
+
+ if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
+ options = append(options, struct {
+ name string
+ description string
+ }{"Hyprland", "Dynamic tiling Wayland compositor."})
+ }
+
+ for i, option := range options {
+ if i == m.selectedWM {
+ selected := m.styles.SelectedOption.Render("▶ " + option.name)
+ b.WriteString(selected)
+ b.WriteString("\n")
+ desc := m.styles.Subtle.Render(" " + option.description)
+ b.WriteString(desc)
+ } else {
+ normal := m.styles.Normal.Render(" " + option.name)
+ b.WriteString(normal)
+ b.WriteString("\n")
+ desc := m.styles.Subtle.Render(" " + option.description)
+ b.WriteString(desc)
+ }
+ b.WriteString("\n")
+ if i < len(options)-1 {
+ b.WriteString("\n")
+ }
+ }
+
+ b.WriteString("\n")
+ help := m.styles.Subtle.Render("Use ↑/↓ to navigate, Enter to select, Esc to go back")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) viewSelectTerminal() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ title := m.styles.Title.Render("Choose Terminal Emulator")
+ b.WriteString(title)
+ b.WriteString("\n\n")
+
+ options := []struct {
+ name string
+ description string
+ }{}
+
+ if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
+ options = []struct {
+ name string
+ description string
+ }{
+ {"kitty", "A feature-rich, customizable terminal emulator."},
+ {"alacritty", "A simple terminal emulator."},
+ }
+ } else {
+ options = []struct {
+ name string
+ description string
+ }{
+ {"ghostty", "A fast, native terminal emulator built in Zig."},
+ {"kitty", "A feature-rich, customizable terminal emulator."},
+ {"alacritty", "A simple terminal emulator."},
+ }
+ }
+
+ for i, option := range options {
+ if i == m.selectedTerminal {
+ selected := m.styles.SelectedOption.Render("▶ " + option.name)
+ b.WriteString(selected)
+ b.WriteString("\n")
+ desc := m.styles.Subtle.Render(" " + option.description)
+ b.WriteString(desc)
+ } else {
+ normal := m.styles.Normal.Render(" " + option.name)
+ b.WriteString(normal)
+ b.WriteString("\n")
+ desc := m.styles.Subtle.Render(" " + option.description)
+ b.WriteString(desc)
+ }
+ b.WriteString("\n")
+ if i < len(options)-1 {
+ b.WriteString("\n")
+ }
+ }
+
+ b.WriteString("\n")
+ help := m.styles.Subtle.Render("Use ↑/↓ to navigate, Enter to select, Esc to go back")
+ b.WriteString(help)
+
+ return b.String()
+}
+
+func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ maxTerminalIndex := 2
+ if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
+ maxTerminalIndex = 1
+ }
+
+ switch keyMsg.String() {
+ case "up":
+ if m.selectedTerminal > 0 {
+ m.selectedTerminal--
+ }
+ case "down":
+ if m.selectedTerminal < maxTerminalIndex {
+ m.selectedTerminal++
+ }
+ case "enter":
+ if m.osInfo != nil && m.osInfo.Distribution.ID == "nixos" {
+ var wmInstalled bool
+ if m.selectedWM == 0 {
+ wmInstalled = m.commandExists("niri")
+ } else {
+ wmInstalled = m.commandExists("hyprland") || m.commandExists("Hyprland")
+ }
+
+ if !wmInstalled {
+ m.state = StateMissingWMInstructions
+ return m, m.listenForLogs()
+ }
+ }
+
+ m.state = StateDetectingDeps
+ m.isLoading = true
+ return m, tea.Batch(m.spinner.Tick, m.detectDependencies())
+ case "esc":
+ m.state = StateSelectWindowManager
+ return m, m.listenForLogs()
+ }
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ maxWMIndex := 1
+ if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
+ maxWMIndex = 0
+ }
+
+ switch keyMsg.String() {
+ case "up":
+ if m.selectedWM > 0 {
+ m.selectedWM--
+ }
+ case "down":
+ if m.selectedWM < maxWMIndex {
+ m.selectedWM++
+ }
+ case "enter":
+ m.state = StateSelectTerminal
+ return m, m.listenForLogs()
+ case "esc":
+ // Go back to welcome screen
+ m.state = StateWelcome
+ return m, m.listenForLogs()
+ }
+ }
+ return m, m.listenForLogs()
+}
+
+func (m Model) commandExists(cmd string) bool {
+ _, err := exec.LookPath(cmd)
+ return err == nil
+}
+
+func (m Model) detectDependencies() tea.Cmd {
+ return func() tea.Msg {
+ if m.osInfo == nil {
+ return depsDetectedMsg{deps: nil, err: fmt.Errorf("OS info not available")}
+ }
+
+ detector, err := distros.NewDependencyDetector(m.osInfo.Distribution.ID, m.logChan)
+ if err != nil {
+ return depsDetectedMsg{deps: nil, err: err}
+ }
+
+ // Convert TUI selection to deps enum
+ var wm deps.WindowManager
+ if m.selectedWM == 0 {
+ wm = deps.WindowManagerNiri // First option is Niri
+ } else {
+ wm = deps.WindowManagerHyprland // Second option is Hyprland
+ }
+
+ var terminal deps.Terminal
+ if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
+ switch m.selectedTerminal {
+ case 0:
+ terminal = deps.TerminalKitty
+ case 1:
+ terminal = deps.TerminalAlacritty
+ default:
+ terminal = deps.TerminalKitty
+ }
+ } else {
+ switch m.selectedTerminal {
+ case 0:
+ terminal = deps.TerminalGhostty
+ case 1:
+ terminal = deps.TerminalKitty
+ default:
+ terminal = deps.TerminalAlacritty
+ }
+ }
+
+ dependencies, err := detector.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
+ return depsDetectedMsg{deps: dependencies, err: err}
+ }
+}
diff --git a/backend/internal/tui/views_welcome.go b/backend/internal/tui/views_welcome.go
new file mode 100644
index 00000000..187c3226
--- /dev/null
+++ b/backend/internal/tui/views_welcome.go
@@ -0,0 +1,216 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/backend/internal/distros"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m Model) viewWelcome() string {
+ var b strings.Builder
+
+ b.WriteString(m.renderBanner())
+ b.WriteString("\n")
+
+ theme := TerminalTheme()
+
+ decorator := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Accent)).
+ Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+
+ titleBox := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color(theme.Primary)).
+ Padding(0, 2).
+ MarginBottom(1)
+
+ titleText := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)).
+ Bold(true).
+ Render("dankinstall")
+
+ versionTag := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Accent)).
+ Italic(true).
+ Render(" // Dank Linux Installer")
+
+ subtitle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Subtle)).
+ Italic(true).
+ Render("Quickstart for a Dank™ Desktop")
+
+ b.WriteString(decorator)
+ b.WriteString("\n")
+ b.WriteString(titleBox.Render(titleText + versionTag))
+ b.WriteString("\n")
+ b.WriteString(subtitle)
+ b.WriteString("\n\n")
+
+ if m.osInfo != nil {
+ if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) {
+ errorBox := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("#FF6B6B")).
+ Padding(1, 2).
+ MarginBottom(1)
+
+ errorTitle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#FF6B6B")).
+ Bold(true).
+ Render("⚠ UNSUPPORTED DISTRIBUTION")
+
+ var errorMsg string
+ switch m.osInfo.Distribution.ID {
+ case "ubuntu":
+ errorMsg = fmt.Sprintf("Ubuntu %s is not supported.\n\nOnly Ubuntu 25.04+ is supported.\n\nPlease upgrade to Ubuntu 25.04 or later.", m.osInfo.VersionID)
+ case "debian":
+ errorMsg = fmt.Sprintf("Debian %s is not supported.\n\nOnly Debian 13+ (Trixie) is supported.\n\nPlease upgrade to Debian 13 or later.", m.osInfo.VersionID)
+ case "nixos":
+ errorMsg = "NixOS is currently not supported, but there is a DankMaterialShell flake available."
+ default:
+ errorMsg = fmt.Sprintf("%s is not supported.\nFeel free to request on https://github.com/AvengeMedia/DankMaterialShell/backend", m.osInfo.PrettyName)
+ }
+
+ errorMsgStyled := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Text)).
+ Render(errorMsg)
+
+ b.WriteString(errorBox.Render(errorTitle + "\n\n" + errorMsgStyled))
+ b.WriteString("\n\n")
+ } else {
+ // System info box
+ sysBox := lipgloss.NewStyle().
+ Border(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color(theme.Subtle)).
+ Padding(0, 1).
+ MarginBottom(1)
+
+ // Style the distro name with its color
+ distroStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(m.osInfo.Distribution.HexColorCode)).
+ Bold(true)
+ distroName := distroStyle.Render(m.osInfo.PrettyName)
+
+ archStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Accent))
+
+ sysInfo := fmt.Sprintf("System: %s / %s", distroName, archStyle.Render(m.osInfo.Architecture))
+ b.WriteString(sysBox.Render(sysInfo))
+ b.WriteString("\n")
+
+ // Feature list with better styling
+ featTitle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)).
+ Bold(true).
+ Underline(true).
+ Render("WHAT YOU GET")
+ b.WriteString(featTitle + "\n\n")
+
+ features := []string{
+ "[shell] dms (DankMaterialShell)",
+ "[wm] niri or Hyprland",
+ "[term] Ghostty, kitty, or Alacritty",
+ "[style] All the themes, automatically.",
+ "[config] DANK defaults - keybindings, rules, animations, etc.",
+ }
+
+ for i, feat := range features {
+ prefix := feat[:9]
+ content := feat[10:]
+
+ prefixStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Accent)).
+ Bold(true)
+
+ contentStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Text))
+
+ if i == len(features)-1 {
+ contentStyle = contentStyle.Bold(true)
+ }
+
+ b.WriteString(fmt.Sprintf(" %s %s\n",
+ prefixStyle.Render(prefix),
+ contentStyle.Render(content)))
+ }
+
+ b.WriteString("\n")
+
+ noteStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Subtle)).
+ Italic(true)
+ note := noteStyle.Render("* Existing configs can be replaced (and backed up) or preserved")
+ b.WriteString(note)
+ b.WriteString("\n")
+
+ if m.osInfo.Distribution.ID == "gentoo" {
+ gentooNote := noteStyle.Render("* Will set per-package USE flags and unmask testing packages as needed")
+ b.WriteString(gentooNote)
+ b.WriteString("\n")
+ }
+
+ b.WriteString("\n")
+ }
+
+ } else if m.isLoading {
+ spinner := m.spinner.View()
+ loading := m.styles.Normal.Render("Detecting system...")
+ b.WriteString(fmt.Sprintf("%s %s\n\n", spinner, loading))
+ }
+
+ // Footer with better visual separation
+ footerDivider := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Subtle)).
+ Render("───────────────────────────────────────────────────────────")
+ b.WriteString(footerDivider + "\n")
+
+ if m.osInfo != nil {
+ ctrlKey := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)).
+ Bold(true).
+ Render("Ctrl+C")
+
+ if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) {
+ b.WriteString(m.styles.Subtle.Render("Press ") + ctrlKey + m.styles.Subtle.Render(" to quit"))
+ } else {
+ enterKey := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(theme.Primary)).
+ Bold(true).
+ Render("Enter")
+
+ b.WriteString(m.styles.Subtle.Render("Press ") + enterKey + m.styles.Subtle.Render(" to choose window manager, ") + ctrlKey + m.styles.Subtle.Render(" to quit"))
+ }
+ } else {
+ help := m.styles.Subtle.Render("Press Enter to continue, Ctrl+C to quit")
+ b.WriteString(help)
+ }
+
+ return b.String()
+}
+
+func (m Model) updateWelcomeState(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if completeMsg, ok := msg.(osInfoCompleteMsg); ok {
+ m.isLoading = false
+ if completeMsg.err != nil {
+ m.err = completeMsg.err
+ m.state = StateError
+ } else {
+ m.osInfo = completeMsg.info
+ }
+ return m, m.listenForLogs()
+ }
+
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "enter":
+ if m.osInfo != nil && !distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) {
+ m.state = StateSelectWindowManager
+ return m, m.listenForLogs()
+ }
+ }
+ }
+ return m, m.listenForLogs()
+}
diff --git a/backend/internal/utils/math.go b/backend/internal/utils/math.go
new file mode 100644
index 00000000..408af9f0
--- /dev/null
+++ b/backend/internal/utils/math.go
@@ -0,0 +1,13 @@
+package utils
+
+import "golang.org/x/exp/constraints"
+
+func Clamp[T constraints.Ordered](val, min, max T) T {
+ if val < min {
+ return min
+ }
+ if val > max {
+ return max
+ }
+ return val
+}
diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go
new file mode 100644
index 00000000..2993f306
--- /dev/null
+++ b/backend/internal/version/version.go
@@ -0,0 +1,221 @@
+package version
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+type VersionInfo struct {
+ Current string
+ Latest string
+ IsGit bool
+ IsBranch bool
+ IsTag bool
+ HasUpdate bool
+}
+
+func GetCurrentDMSVersion() (string, error) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get home directory: %w", err)
+ }
+
+ dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
+ if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
+ return "", fmt.Errorf("DMS not installed")
+ }
+
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ defer os.Chdir(originalDir)
+
+ if err := os.Chdir(dmsPath); err != nil {
+ return "", fmt.Errorf("failed to change to DMS directory: %w", err)
+ }
+
+ if _, err := os.Stat(filepath.Join(dmsPath, ".git")); err == nil {
+ tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD")
+ if tagOutput, err := tagCmd.Output(); err == nil {
+ return strings.TrimSpace(string(tagOutput)), nil
+ }
+
+ branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
+ if branchOutput, err := branchCmd.Output(); err == nil {
+ branch := strings.TrimSpace(string(branchOutput))
+ revCmd := exec.Command("git", "rev-parse", "--short", "HEAD")
+ if revOutput, err := revCmd.Output(); err == nil {
+ rev := strings.TrimSpace(string(revOutput))
+ return fmt.Sprintf("%s@%s", branch, rev), nil
+ }
+ return branch, nil
+ }
+ }
+
+ cmd := exec.Command("dms", "--version")
+ if output, err := cmd.Output(); err == nil {
+ return strings.TrimSpace(string(output)), nil
+ }
+
+ return "unknown", nil
+}
+
+func GetLatestDMSVersion() (string, error) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get home directory: %w", err)
+ }
+
+ dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
+
+ originalDir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ defer os.Chdir(originalDir)
+
+ if _, err := os.Stat(filepath.Join(dmsPath, ".git")); err == nil {
+ if err := os.Chdir(dmsPath); err != nil {
+ return "", fmt.Errorf("failed to change to DMS directory: %w", err)
+ }
+
+ currentRefCmd := exec.Command("git", "symbolic-ref", "-q", "HEAD")
+ currentRefOutput, _ := currentRefCmd.Output()
+ onBranch := len(currentRefOutput) > 0
+
+ if !onBranch {
+ tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD")
+ if _, err := tagCmd.Output(); err == nil {
+ // Add timeout to git fetch to prevent hanging
+ fetchCmd := exec.Command("timeout", "5s", "git", "fetch", "origin", "--tags", "--quiet")
+ fetchCmd.Run()
+
+ 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")
+ }
+ return tags[0], nil
+ }
+ } else {
+ branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
+ branchOutput, err := branchCmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("failed to get current branch: %w", err)
+ }
+ currentBranch := strings.TrimSpace(string(branchOutput))
+
+ // Add timeout to git fetch to prevent hanging
+ fetchCmd := exec.Command("timeout", "5s", "git", "fetch", "origin", currentBranch, "--quiet")
+ fetchCmd.Run()
+
+ remoteRevCmd := exec.Command("git", "rev-parse", "--short", fmt.Sprintf("origin/%s", currentBranch))
+ remoteRevOutput, err := remoteRevCmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("failed to get remote revision: %w", err)
+ }
+ remoteRev := strings.TrimSpace(string(remoteRevOutput))
+ return fmt.Sprintf("%s@%s", currentBranch, remoteRev), nil
+ }
+ }
+
+ // Add timeout to prevent hanging when GitHub is down
+ cmd := exec.Command("curl", "-s", "--max-time", "5", "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)
+ }
+
+ var result struct {
+ TagName string `json:"tag_name"`
+ }
+ if err := json.Unmarshal(output, &result); err != nil {
+ for _, line := range strings.Split(string(output), "\n") {
+ if strings.Contains(line, "\"tag_name\"") {
+ parts := strings.Split(line, "\"")
+ if len(parts) >= 4 {
+ return parts[3], nil
+ }
+ }
+ }
+ return "", fmt.Errorf("failed to parse latest version: %w", err)
+ }
+
+ return result.TagName, nil
+}
+
+func GetDMSVersionInfo() (*VersionInfo, error) {
+ current, err := GetCurrentDMSVersion()
+ if err != nil {
+ return nil, err
+ }
+
+ latest, err := GetLatestDMSVersion()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get latest version: %w", err)
+ }
+
+ info := &VersionInfo{
+ Current: current,
+ Latest: latest,
+ IsGit: strings.Contains(current, "@"),
+ IsBranch: strings.Contains(current, "@"),
+ IsTag: !strings.Contains(current, "@") && strings.HasPrefix(current, "v"),
+ }
+
+ if info.IsBranch {
+ parts := strings.Split(current, "@")
+ latestParts := strings.Split(latest, "@")
+ if len(parts) == 2 && len(latestParts) == 2 {
+ info.HasUpdate = parts[1] != latestParts[1]
+ }
+ } else if info.IsTag {
+ info.HasUpdate = current != latest
+ } else {
+ info.HasUpdate = false
+ }
+
+ return info, nil
+}
+
+func CompareVersions(v1, v2 string) int {
+ v1 = strings.TrimPrefix(v1, "v")
+ v2 = strings.TrimPrefix(v2, "v")
+
+ parts1 := strings.Split(v1, ".")
+ parts2 := strings.Split(v2, ".")
+
+ maxLen := len(parts1)
+ if len(parts2) > maxLen {
+ maxLen = len(parts2)
+ }
+
+ for i := 0; i < maxLen; i++ {
+ var p1, p2 int
+ if i < len(parts1) {
+ fmt.Sscanf(parts1[i], "%d", &p1)
+ }
+ if i < len(parts2) {
+ fmt.Sscanf(parts2[i], "%d", &p2)
+ }
+
+ if p1 < p2 {
+ return -1
+ }
+ if p1 > p2 {
+ return 1
+ }
+ }
+
+ return 0
+}
diff --git a/backend/internal/version/version_test.go b/backend/internal/version/version_test.go
new file mode 100644
index 00000000..08b123a3
--- /dev/null
+++ b/backend/internal/version/version_test.go
@@ -0,0 +1,293 @@
+package version
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+func TestCompareVersions(t *testing.T) {
+ 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},
+ {"v0.1.10", "v0.1.2", 1},
+ {"v0.2.0", "v0.1.9", 1},
+ {"0.1.0", "0.1.0", 0},
+ {"1.0.0", "v1.0.0", 0},
+ {"v1.2.3", "v1.2.4", -1},
+ {"v2.0.0", "v1.9.9", 1},
+ }
+
+ for _, tt := range tests {
+ result := CompareVersions(tt.v1, tt.v2)
+ if result != tt.expected {
+ t.Errorf("CompareVersions(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
+ }
+ }
+}
+
+func TestGetDMSVersionInfo_Structure(t *testing.T) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ t.Skip("Cannot get home directory")
+ }
+
+ dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
+ if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
+ t.Skip("DMS not installed, skipping version info test")
+ }
+
+ info, err := GetDMSVersionInfo()
+ if err != nil {
+ t.Fatalf("GetDMSVersionInfo() failed: %v", err)
+ }
+
+ if info == nil {
+ t.Fatal("GetDMSVersionInfo() returned nil")
+ }
+
+ if info.Current == "" {
+ t.Error("Current version is empty")
+ }
+
+ if info.Latest == "" {
+ t.Error("Latest version is empty")
+ }
+
+ t.Logf("Current: %s, Latest: %s, HasUpdate: %v", info.Current, info.Latest, info.HasUpdate)
+}
+
+func TestGetCurrentDMSVersion_NotInstalled(t *testing.T) {
+ originalHome := os.Getenv("HOME")
+ defer os.Setenv("HOME", originalHome)
+
+ tempDir := t.TempDir()
+ os.Setenv("HOME", tempDir)
+
+ _, err := GetCurrentDMSVersion()
+ if err == nil {
+ t.Error("Expected error when DMS not installed, got nil")
+ }
+}
+
+func TestGetCurrentDMSVersion_GitTag(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()
+
+ 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.1.0").Run()
+
+ version, err := GetCurrentDMSVersion()
+ if err != nil {
+ t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
+ }
+
+ if version != "v0.1.0" {
+ t.Errorf("Expected version v0.1.0, got %s", version)
+ }
+}
+
+func TestGetCurrentDMSVersion_GitBranch(t *testing.T) {
+ if !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()
+
+ version, err := GetCurrentDMSVersion()
+ if err != nil {
+ t.Fatalf("GetCurrentDMSVersion() failed: %v", err)
+ }
+
+ if version == "" {
+ t.Error("Expected non-empty version")
+ }
+
+ if len(version) < 7 {
+ t.Errorf("Expected version with branch@commit format, got %s", version)
+ }
+}
+
+func TestVersionInfo_IsGit(t *testing.T) {
+ tests := []struct {
+ current string
+ isGit bool
+ isBranch bool
+ isTag bool
+ }{
+ {"v0.1.0", false, false, true},
+ {"master@abc1234", true, true, false},
+ {"dev@def5678", true, true, false},
+ {"v0.2.0", false, false, true},
+ {"unknown", false, false, false},
+ }
+
+ for _, tt := range tests {
+ info := &VersionInfo{
+ IsGit: tt.isGit,
+ IsBranch: tt.isBranch,
+ IsTag: tt.isTag,
+ }
+
+ actualIsGit := len(tt.current) > 0 && tt.current[0] != 'v' && tt.current != "unknown"
+ actualIsBranch := len(tt.current) > 0 && tt.current[0] != 'v'
+ actualIsTag := len(tt.current) > 0 && tt.current[0] == 'v'
+
+ if tt.current == "unknown" {
+ actualIsGit = false
+ actualIsBranch = false
+ actualIsTag = false
+ }
+
+ if info.IsGit != tt.isGit {
+ t.Errorf("For %s: IsGit = %v; want %v", tt.current, info.IsGit, tt.isGit)
+ }
+ if info.IsBranch != tt.isBranch {
+ t.Errorf("For %s: IsBranch = %v; want %v", tt.current, info.IsBranch, tt.isBranch)
+ }
+ if info.IsTag != tt.isTag {
+ t.Errorf("For %s: IsTag = %v; want %v", tt.current, info.IsTag, tt.isTag)
+ }
+
+ _ = actualIsGit
+ _ = actualIsBranch
+ _ = actualIsTag
+ }
+}
+
+func TestVersionInfo_HasUpdate_Branch(t *testing.T) {
+ tests := []struct {
+ current string
+ latest string
+ hasUpdate bool
+ }{
+ {"master@abc1234", "master@abc1234", false},
+ {"master@abc1234", "master@def5678", true},
+ {"dev@abc1234", "dev@abc1234", false},
+ {"dev@old1234", "dev@new5678", true},
+ }
+
+ for _, tt := range tests {
+ info := &VersionInfo{
+ HasUpdate: tt.hasUpdate,
+ }
+
+ if info.HasUpdate != tt.hasUpdate {
+ t.Errorf("For current=%s, latest=%s: HasUpdate = %v; want %v",
+ tt.current, tt.latest, info.HasUpdate, tt.hasUpdate)
+ }
+ }
+}
+
+func TestVersionInfo_HasUpdate_Tag(t *testing.T) {
+ tests := []struct {
+ current string
+ latest string
+ hasUpdate bool
+ }{
+ {"v0.1.0", "v0.1.0", false},
+ {"v0.1.0", "v0.1.1", true},
+ {"v0.1.5", "v0.1.5", false},
+ {"v0.1.9", "v0.2.0", true},
+ }
+
+ for _, tt := range tests {
+ info := &VersionInfo{
+ HasUpdate: tt.hasUpdate,
+ }
+
+ if info.HasUpdate != tt.hasUpdate {
+ t.Errorf("For current=%s, latest=%s: HasUpdate = %v; want %v",
+ tt.current, tt.latest, info.HasUpdate, tt.hasUpdate)
+ }
+ }
+}
+
+func commandExists(cmd string) bool {
+ _, err := exec.LookPath(cmd)
+ return err == nil
+}
+
+func TestGetLatestDMSVersion_FallbackParsing(t *testing.T) {
+ jsonResponse := `{
+ "tag_name": "v0.1.17",
+ "name": "Release v0.1.17"
+ }`
+
+ lines := []string{
+ ` "tag_name": "v0.1.17",`,
+ ` "name": "Release v0.1.17"`,
+ }
+
+ for _, line := range lines {
+ if len(line) > 0 && line[0:15] == ` "tag_name": "` {
+ parts := []string{"", "", "", "v0.1.17"}
+ version := parts[3]
+ if version != "v0.1.17" {
+ t.Errorf("Failed to parse version from line: %s", line)
+ }
+ }
+ }
+
+ _ = jsonResponse
+}
+
+func TestCompareVersions_EdgeCases(t *testing.T) {
+ tests := []struct {
+ v1 string
+ v2 string
+ expected int
+ }{
+ {"", "", 0},
+ {"v1", "v1", 0},
+ {"v1.0", "v1", 0},
+ {"v1.0.0", "v1.0", 0},
+ {"v1.0.1", "v1.0", 1},
+ {"v1", "v1.0.1", -1},
+ }
+
+ for _, tt := range tests {
+ result := CompareVersions(tt.v1, tt.v2)
+ if result != tt.expected {
+ t.Errorf("CompareVersions(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected)
+ }
+ }
+}
diff --git a/backend/pkg/ipp/CREDITS.MD b/backend/pkg/ipp/CREDITS.MD
new file mode 100644
index 00000000..b7d28cc1
--- /dev/null
+++ b/backend/pkg/ipp/CREDITS.MD
@@ -0,0 +1,5 @@
+# Credits
+
+* Original socket adapter code is mostly taken from [korylprince/printer-manager-cups](https://github.com/korylprince/printer-manager-cups)
+([MIT](https://github.com/korylprince/printer-manager-cups/blob/v1.0.9/LICENSE) licensed):
+[conn.go](https://github.com/korylprince/printer-manager-cups/blob/v1.0.9/cups/conn.go)
diff --git a/backend/pkg/ipp/LICENSE b/backend/pkg/ipp/LICENSE
new file mode 100644
index 00000000..261eeb9e
--- /dev/null
+++ b/backend/pkg/ipp/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/backend/pkg/ipp/adapter-http.go b/backend/pkg/ipp/adapter-http.go
new file mode 100644
index 00000000..0862808f
--- /dev/null
+++ b/backend/pkg/ipp/adapter-http.go
@@ -0,0 +1,132 @@
+package ipp
+
+import (
+ "bytes"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+type HttpAdapter struct {
+ host string
+ port int
+ username string
+ password string
+ useTLS bool
+ client *http.Client
+}
+
+func NewHttpAdapter(host string, port int, username, password string, useTLS bool) *HttpAdapter {
+ httpClient := http.Client{
+ Timeout: 0,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ ResponseHeaderTimeout: 90 * time.Second,
+ IdleConnTimeout: 120 * time.Second,
+ },
+ }
+
+ return &HttpAdapter{
+ host: host,
+ port: port,
+ username: username,
+ password: password,
+ useTLS: useTLS,
+ client: &httpClient,
+ }
+}
+
+func (h *HttpAdapter) SendRequest(url string, req *Request, additionalResponseData io.Writer) (*Response, error) {
+ payload, err := req.Encode()
+ if err != nil {
+ return nil, err
+ }
+
+ size := len(payload)
+ var body io.Reader
+ if req.File != nil && req.FileSize != -1 {
+ size += req.FileSize
+ body = io.MultiReader(bytes.NewBuffer(payload), req.File)
+ } else {
+ body = bytes.NewBuffer(payload)
+ }
+
+ httpReq, err := http.NewRequest("POST", url, body)
+ if err != nil {
+ return nil, err
+ }
+
+ httpReq.Header.Set("Content-Length", strconv.Itoa(size))
+ httpReq.Header.Set("Content-Type", ContentTypeIPP)
+
+ if h.username != "" && h.password != "" {
+ httpReq.SetBasicAuth(h.username, h.password)
+ }
+
+ httpResp, err := h.client.Do(httpReq)
+ if err != nil {
+ return nil, err
+ }
+ defer httpResp.Body.Close()
+
+ if httpResp.StatusCode != 200 {
+ return nil, HTTPError{
+ Code: httpResp.StatusCode,
+ }
+ }
+
+ // buffer response to avoid read issues
+ buf := new(bytes.Buffer)
+ if httpResp.ContentLength > 0 {
+ buf.Grow(int(httpResp.ContentLength))
+ }
+ if _, err := io.Copy(buf, httpResp.Body); err != nil {
+ return nil, fmt.Errorf("unable to buffer response: %w", err)
+ }
+
+ ippResp, err := NewResponseDecoder(buf).Decode(additionalResponseData)
+ if err != nil {
+ return nil, err
+ }
+
+ if err = ippResp.CheckForErrors(); err != nil {
+ return nil, fmt.Errorf("received error IPP response: %w", err)
+ }
+
+ return ippResp, nil
+}
+
+func (h *HttpAdapter) GetHttpUri(namespace string, object interface{}) string {
+ proto := "http"
+ if h.useTLS {
+ proto = "https"
+ }
+
+ uri := fmt.Sprintf("%s://%s:%d", proto, h.host, h.port)
+
+ if namespace != "" {
+ uri = fmt.Sprintf("%s/%s", uri, namespace)
+ }
+
+ if object != nil {
+ uri = fmt.Sprintf("%s/%v", uri, object)
+ }
+
+ return uri
+}
+
+func (h *HttpAdapter) TestConnection() error {
+ conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", h.host, h.port))
+ if err != nil {
+ return err
+ }
+ conn.Close()
+
+ return nil
+}
diff --git a/backend/pkg/ipp/adapter.go b/backend/pkg/ipp/adapter.go
new file mode 100644
index 00000000..15ad2d05
--- /dev/null
+++ b/backend/pkg/ipp/adapter.go
@@ -0,0 +1,9 @@
+package ipp
+
+import "io"
+
+type Adapter interface {
+ SendRequest(url string, req *Request, additionalResponseData io.Writer) (*Response, error)
+ GetHttpUri(namespace string, object interface{}) string
+ TestConnection() error
+}
diff --git a/backend/pkg/ipp/attribute.go b/backend/pkg/ipp/attribute.go
new file mode 100644
index 00000000..a9b4ba7f
--- /dev/null
+++ b/backend/pkg/ipp/attribute.go
@@ -0,0 +1,528 @@
+package ipp
+
+import (
+ "encoding/binary"
+ "fmt"
+ "io"
+)
+
+const (
+ sizeInteger = int16(4)
+ sizeBoolean = int16(1)
+)
+
+// AttributeEncoder encodes attribute to a io.Writer
+type AttributeEncoder struct {
+ writer io.Writer
+}
+
+// NewAttributeEncoder returns a new encoder that writes to w
+func NewAttributeEncoder(w io.Writer) *AttributeEncoder {
+ return &AttributeEncoder{w}
+}
+
+// Encode encodes a attribute and its value to a io.Writer
+// the tag is determined by the AttributeTagMapping map
+func (e *AttributeEncoder) Encode(attribute string, value interface{}) error {
+ tag, ok := AttributeTagMapping[attribute]
+ if !ok {
+ return fmt.Errorf("cannot get tag of attribute %s", attribute)
+ }
+
+ switch v := value.(type) {
+ case int:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+
+ if err := e.encodeInteger(int32(v)); err != nil {
+ return err
+ }
+ case int16:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+
+ if err := e.encodeInteger(int32(v)); err != nil {
+ return err
+ }
+ case int8:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+
+ if err := e.encodeInteger(int32(v)); err != nil {
+ return err
+ }
+ case int32:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+
+ if err := e.encodeInteger(v); err != nil {
+ return err
+ }
+ case int64:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+
+ if err := e.encodeInteger(int32(v)); err != nil {
+ return err
+ }
+ case []int:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ for index, val := range v {
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if index == 0 {
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+ } else {
+ if err := e.writeNullByte(); err != nil {
+ return err
+ }
+ }
+
+ if err := e.encodeInteger(int32(val)); err != nil {
+ return err
+ }
+ }
+ case []int16:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ for index, val := range v {
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if index == 0 {
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+ } else {
+ if err := e.writeNullByte(); err != nil {
+ return err
+ }
+ }
+
+ if err := e.encodeInteger(int32(val)); err != nil {
+ return err
+ }
+ }
+ case []int8:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ for index, val := range v {
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if index == 0 {
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+ } else {
+ if err := e.writeNullByte(); err != nil {
+ return err
+ }
+ }
+
+ if err := e.encodeInteger(int32(val)); err != nil {
+ return err
+ }
+ }
+ case []int32:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ for index, val := range v {
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if index == 0 {
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+ } else {
+ if err := e.writeNullByte(); err != nil {
+ return err
+ }
+ }
+
+ if err := e.encodeInteger(val); err != nil {
+ return err
+ }
+ }
+ case []int64:
+ if tag != TagInteger && tag != TagEnum {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ for index, val := range v {
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if index == 0 {
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+ } else {
+ if err := e.writeNullByte(); err != nil {
+ return err
+ }
+ }
+
+ if err := e.encodeInteger(int32(val)); err != nil {
+ return err
+ }
+ }
+ case bool:
+ if tag != TagBoolean {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+
+ if err := e.encodeBoolean(v); err != nil {
+ return err
+ }
+ case []bool:
+ if tag != TagBoolean {
+ return fmt.Errorf("tag for attribute %s does not match with value type", attribute)
+ }
+
+ for index, val := range v {
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if index == 0 {
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+ } else {
+ if err := e.writeNullByte(); err != nil {
+ return err
+ }
+ }
+
+ if err := e.encodeBoolean(val); err != nil {
+ return err
+ }
+ }
+ case string:
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+
+ if err := e.encodeString(v); err != nil {
+ return err
+ }
+ case []string:
+ for index, val := range v {
+ if err := e.encodeTag(tag); err != nil {
+ return err
+ }
+
+ if index == 0 {
+ if err := e.encodeString(attribute); err != nil {
+ return err
+ }
+ } else {
+ if err := e.writeNullByte(); err != nil {
+ return err
+ }
+ }
+
+ if err := e.encodeString(val); err != nil {
+ return err
+ }
+ }
+ default:
+ return fmt.Errorf("type %T is not supported", value)
+ }
+
+ return nil
+}
+
+func (e *AttributeEncoder) encodeString(s string) error {
+ if err := binary.Write(e.writer, binary.BigEndian, int16(len(s))); err != nil {
+ return err
+ }
+
+ _, err := e.writer.Write([]byte(s))
+ return err
+}
+
+func (e *AttributeEncoder) encodeInteger(i int32) error {
+ if err := binary.Write(e.writer, binary.BigEndian, sizeInteger); err != nil {
+ return err
+ }
+
+ return binary.Write(e.writer, binary.BigEndian, i)
+}
+
+func (e *AttributeEncoder) encodeBoolean(b bool) error {
+ if err := binary.Write(e.writer, binary.BigEndian, sizeBoolean); err != nil {
+ return err
+ }
+
+ return binary.Write(e.writer, binary.BigEndian, b)
+}
+
+func (e *AttributeEncoder) encodeTag(t int8) error {
+ return binary.Write(e.writer, binary.BigEndian, t)
+}
+
+func (e *AttributeEncoder) writeNullByte() error {
+ return binary.Write(e.writer, binary.BigEndian, int16(0))
+}
+
+// Attribute defines an ipp attribute
+type Attribute struct {
+ Tag int8
+ Name string
+ Value interface{}
+}
+
+// Resolution defines the resolution attribute
+type Resolution struct {
+ Height int32
+ Width int32
+ Depth int8
+}
+
+// AttributeDecoder reads and decodes ipp from an input stream
+type AttributeDecoder struct {
+ reader io.Reader
+}
+
+// NewAttributeDecoder returns a new decoder that reads from r
+func NewAttributeDecoder(r io.Reader) *AttributeDecoder {
+ return &AttributeDecoder{r}
+}
+
+// Decode reads the next ipp attribute into a attribute struct. the type is identified by a tag passed as an argument
+func (d *AttributeDecoder) Decode(tag int8) (*Attribute, error) {
+ attr := Attribute{Tag: tag}
+
+ name, err := d.decodeString()
+ if err != nil {
+ return nil, err
+ }
+ attr.Name = name
+
+ switch attr.Tag {
+ case TagEnum, TagInteger:
+ val, err := d.decodeInteger()
+ if err != nil {
+ return nil, err
+ }
+ attr.Value = val
+ case TagBoolean:
+ val, err := d.decodeBool()
+ if err != nil {
+ return nil, err
+ }
+ attr.Value = val
+ case TagDate:
+ val, err := d.decodeDate()
+ if err != nil {
+ return nil, err
+ }
+ attr.Value = val
+ case TagRange:
+ val, err := d.decodeRange()
+ if err != nil {
+ return nil, err
+ }
+ attr.Value = val
+ case TagResolution:
+ val, err := d.decodeResolution()
+ if err != nil {
+ return nil, err
+ }
+ attr.Value = val
+ default:
+ val, err := d.decodeString()
+ if err != nil {
+ return nil, err
+ }
+ attr.Value = val
+ }
+
+ return &attr, nil
+}
+
+func (d *AttributeDecoder) decodeBool() (b bool, err error) {
+ if _, err = d.readValueLength(); err != nil {
+ return
+ }
+
+ if err = binary.Read(d.reader, binary.BigEndian, &b); err != nil {
+ return
+ }
+
+ return
+}
+
+func (d *AttributeDecoder) decodeInteger() (i int, err error) {
+ if _, err = d.readValueLength(); err != nil {
+ return
+ }
+
+ var reti int32
+ if err = binary.Read(d.reader, binary.BigEndian, &reti); err != nil {
+ return
+ }
+
+ return int(reti), nil
+}
+
+func (d *AttributeDecoder) decodeString() (string, error) {
+ length, err := d.readValueLength()
+ if err != nil {
+ return "", err
+ }
+
+ if length == 0 {
+ return "", nil
+ }
+
+ bs := make([]byte, length)
+ if _, err := d.reader.Read(bs); err != nil {
+ return "", nil
+ }
+
+ return string(bs), nil
+}
+
+func (d *AttributeDecoder) decodeDate() ([]int, error) {
+ length, err := d.readValueLength()
+ if err != nil {
+ return nil, err
+ }
+
+ is := make([]int, length)
+ var ti int8
+
+ for i := int16(0); i < length; i++ {
+ if err = binary.Read(d.reader, binary.BigEndian, &ti); err != nil {
+ return nil, err
+ }
+ is[i] = int(ti)
+ }
+
+ return is, nil
+}
+
+func (d *AttributeDecoder) decodeRange() ([]int32, error) {
+ length, err := d.readValueLength()
+ if err != nil {
+ return nil, err
+ }
+
+ // initialize range element count (c) and range slice (r)
+ c := length / 4
+ r := make([]int32, c)
+
+ for i := int16(0); i < c; i++ {
+ var ti int32
+ if err = binary.Read(d.reader, binary.BigEndian, &ti); err != nil {
+ return nil, err
+ }
+ r[i] = ti
+ }
+
+ return r, nil
+}
+
+func (d *AttributeDecoder) decodeResolution() (res Resolution, err error) {
+ _, err = d.readValueLength()
+ if err != nil {
+ return
+ }
+
+ if err = binary.Read(d.reader, binary.BigEndian, &res.Height); err != nil {
+ return
+ }
+
+ if err = binary.Read(d.reader, binary.BigEndian, &res.Width); err != nil {
+ return
+ }
+
+ if err = binary.Read(d.reader, binary.BigEndian, &res.Depth); err != nil {
+ return
+ }
+
+ return
+}
+
+func (d *AttributeDecoder) readValueLength() (length int16, err error) {
+ err = binary.Read(d.reader, binary.BigEndian, &length)
+ return
+}
diff --git a/backend/pkg/ipp/constants.go b/backend/pkg/ipp/constants.go
new file mode 100644
index 00000000..5c91983e
--- /dev/null
+++ b/backend/pkg/ipp/constants.go
@@ -0,0 +1,449 @@
+package ipp
+
+// ipp status codes
+const (
+ StatusCupsInvalid int16 = -1
+ StatusOk int16 = 0x0000
+ StatusOkIgnoredOrSubstituted int16 = 0x0001
+ StatusOkConflicting int16 = 0x0002
+ StatusOkIgnoredSubscriptions int16 = 0x0003
+ StatusOkIgnoredNotifications int16 = 0x0004
+ StatusOkTooManyEvents int16 = 0x0005
+ StatusOkButCancelSubscription int16 = 0x0006
+ StatusOkEventsComplete int16 = 0x0007
+ StatusRedirectionOtherSite int16 = 0x0200
+ StatusCupsSeeOther int16 = 0x0280
+ StatusErrorBadRequest int16 = 0x0400
+ StatusErrorForbidden int16 = 0x0401
+ StatusErrorNotAuthenticated int16 = 0x0402
+ StatusErrorNotAuthorized int16 = 0x0403
+ StatusErrorNotPossible int16 = 0x0404
+ StatusErrorTimeout int16 = 0x0405
+ StatusErrorNotFound int16 = 0x0406
+ StatusErrorGone int16 = 0x0407
+ StatusErrorRequestEntity int16 = 0x0408
+ StatusErrorRequestValue int16 = 0x0409
+ StatusErrorDocumentFormatNotSupported int16 = 0x040a
+ StatusErrorAttributesOrValues int16 = 0x040b
+ StatusErrorUriScheme int16 = 0x040c
+ StatusErrorCharset int16 = 0x040d
+ StatusErrorConflicting int16 = 0x040e
+ StatusErrorCompressionError int16 = 0x040f
+ StatusErrorDocumentFormatError int16 = 0x0410
+ StatusErrorDocumentAccess int16 = 0x0411
+ StatusErrorAttributesNotSettable int16 = 0x0412
+ StatusErrorIgnoredAllSubscriptions int16 = 0x0413
+ StatusErrorTooManySubscriptions int16 = 0x0414
+ StatusErrorIgnoredAllNotifications int16 = 0x0415
+ StatusErrorPrintSupportFileNotFound int16 = 0x0416
+ StatusErrorDocumentPassword int16 = 0x0417
+ StatusErrorDocumentPermission int16 = 0x0418
+ StatusErrorDocumentSecurity int16 = 0x0419
+ StatusErrorDocumentUnprintable int16 = 0x041a
+ StatusErrorAccountInfoNeeded int16 = 0x041b
+ StatusErrorAccountClosed int16 = 0x041c
+ StatusErrorAccountLimitReached int16 = 0x041d
+ StatusErrorAccountAuthorizationFailed int16 = 0x041e
+ StatusErrorNotFetchable int16 = 0x041f
+ StatusErrorCupsAccountInfoNeeded int16 = 0x049C
+ StatusErrorCupsAccountClosed int16 = 0x049d
+ StatusErrorCupsAccountLimitReached int16 = 0x049e
+ StatusErrorCupsAccountAuthorizationFailed int16 = 0x049f
+ StatusErrorInternal int16 = 0x0500
+ StatusErrorOperationNotSupported int16 = 0x0501
+ StatusErrorServiceUnavailable int16 = 0x0502
+ StatusErrorVersionNotSupported int16 = 0x0503
+ StatusErrorDevice int16 = 0x0504
+ StatusErrorTemporary int16 = 0x0505
+ StatusErrorNotAcceptingJobs int16 = 0x0506
+ StatusErrorBusy int16 = 0x0507
+ StatusErrorJobCanceled int16 = 0x0508
+ StatusErrorMultipleJobsNotSupported int16 = 0x0509
+ StatusErrorPrinterIsDeactivated int16 = 0x050a
+ StatusErrorTooManyJobs int16 = 0x050b
+ StatusErrorTooManyDocuments int16 = 0x050c
+ StatusErrorCupsAuthenticationCanceled int16 = 0x1000
+ StatusErrorCupsPki int16 = 0x1001
+ StatusErrorCupsUpgradeRequired int16 = 0x1002
+)
+
+// ipp operations
+const (
+ OperationCupsInvalid int16 = -0x0001
+ OperationCupsNone int16 = 0x0000
+ OperationPrintJob int16 = 0x0002
+ OperationPrintUri int16 = 0x0003
+ OperationValidateJob int16 = 0x0004
+ OperationCreateJob int16 = 0x0005
+ OperationSendDocument int16 = 0x0006
+ OperationSendUri int16 = 0x0007
+ OperationCancelJob int16 = 0x0008
+ OperationGetJobAttributes int16 = 0x0009
+ OperationGetJobs int16 = 0x000a
+ OperationGetPrinterAttributes int16 = 0x000b
+ OperationHoldJob int16 = 0x000c
+ OperationReleaseJob int16 = 0x000d
+ OperationRestartJob int16 = 0x000e
+ OperationPausePrinter int16 = 0x0010
+ OperationResumePrinter int16 = 0x0011
+ OperationPurgeJobs int16 = 0x0012
+ OperationSetPrinterAttributes int16 = 0x0013
+ OperationSetJobAttributes int16 = 0x0014
+ OperationGetPrinterSupportedValues int16 = 0x0015
+ OperationCreatePrinterSubscriptions int16 = 0x0016
+ OperationCreateJobSubscriptions int16 = 0x0017
+ OperationGetSubscriptionAttributes int16 = 0x0018
+ OperationGetSubscriptions int16 = 0x0019
+ OperationRenewSubscription int16 = 0x001a
+ OperationCancelSubscription int16 = 0x001b
+ OperationGetNotifications int16 = 0x001c
+ OperationSendNotifications int16 = 0x001d
+ OperationGetResourceAttributes int16 = 0x001e
+ OperationGetResourceData int16 = 0x001f
+ OperationGetResources int16 = 0x0020
+ OperationGetPrintSupportFiles int16 = 0x0021
+ OperationEnablePrinter int16 = 0x0022
+ OperationDisablePrinter int16 = 0x0023
+ OperationPausePrinterAfterCurrentJob int16 = 0x0024
+ OperationHoldNewJobs int16 = 0x0025
+ OperationReleaseHeldNewJobs int16 = 0x0026
+ OperationDeactivatePrinter int16 = 0x0027
+ OperationActivatePrinter int16 = 0x0028
+ OperationRestartPrinter int16 = 0x0029
+ OperationShutdownPrinter int16 = 0x002a
+ OperationStartupPrinter int16 = 0x002b
+ OperationReprocessJob int16 = 0x002c
+ OperationCancelCurrentJob int16 = 0x002d
+ OperationSuspendCurrentJob int16 = 0x002e
+ OperationResumeJob int16 = 0x002f
+ OperationOperationPromoteJob int16 = 0x0030
+ OperationScheduleJobAfter int16 = 0x0031
+ OperationCancelDocument int16 = 0x0033
+ OperationGetDocumentAttributes int16 = 0x0034
+ OperationGetDocuments int16 = 0x0035
+ OperationDeleteDocument int16 = 0x0036
+ OperationSetDocumentAttributes int16 = 0x0037
+ OperationCancelJobs int16 = 0x0038
+ OperationCancelMyJobs int16 = 0x0039
+ OperationResubmitJob int16 = 0x003a
+ OperationCloseJob int16 = 0x003b
+ OperationIdentifyPrinter int16 = 0x003c
+ OperationValidateDocument int16 = 0x003d
+ OperationAddDocumentImages int16 = 0x003e
+ OperationAcknowledgeDocument int16 = 0x003f
+ OperationAcknowledgeIdentifyPrinter int16 = 0x0040
+ OperationAcknowledgeJob int16 = 0x0041
+ OperationFetchDocument int16 = 0x0042
+ OperationFetchJob int16 = 0x0043
+ OperationGetOutputDeviceAttributes int16 = 0x0044
+ OperationUpdateActiveJobs int16 = 0x0045
+ OperationDeregisterOutputDevice int16 = 0x0046
+ OperationUpdateDocumentStatus int16 = 0x0047
+ OperationUpdateJobStatus int16 = 0x0048
+ OperationUpdateOutputDeviceAttributes int16 = 0x0049
+ OperationGetNextDocumentData int16 = 0x004a
+ OperationAllocatePrinterResources int16 = 0x004b
+ OperationCreatePrinter int16 = 0x004c
+ OperationDeallocatePrinterResources int16 = 0x004d
+ OperationDeletePrinter int16 = 0x004e
+ OperationGetPrinters int16 = 0x004f
+ OperationShutdownOnePrinter int16 = 0x0050
+ OperationStartupOnePrinter int16 = 0x0051
+ OperationCancelResource int16 = 0x0052
+ OperationCreateResource int16 = 0x0053
+ OperationInstallResource int16 = 0x0054
+ OperationSendResourceData int16 = 0x0055
+ OperationSetResourceAttributes int16 = 0x0056
+ OperationCreateResourceSubscriptions int16 = 0x0057
+ OperationCreateSystemSubscriptions int16 = 0x0058
+ OperationDisableAllPrinters int16 = 0x0059
+ OperationEnableAllPrinters int16 = 0x005a
+ OperationGetSystemAttributes int16 = 0x005b
+ OperationGetSystemSupportedValues int16 = 0x005c
+ OperationPauseAllPrinters int16 = 0x005d
+ OperationPauseAllPrintersAfterCurrentJob int16 = 0x005e
+ OperationRegisterOutputDevice int16 = 0x005f
+ OperationRestartSystem int16 = 0x0060
+ OperationResumeAllPrinters int16 = 0x0061
+ OperationSetSystemAttributes int16 = 0x0062
+ OperationShutdownAllPrinter int16 = 0x0063
+ OperationStartupAllPrinters int16 = 0x0064
+ OperationPrivate int16 = 0x4000
+ OperationCupsGetDefault int16 = 0x4001
+ OperationCupsGetPrinters int16 = 0x4002
+ OperationCupsAddModifyPrinter int16 = 0x4003
+ OperationCupsDeletePrinter int16 = 0x4004
+ OperationCupsGetClasses int16 = 0x4005
+ OperationCupsAddModifyClass int16 = 0x4006
+ OperationCupsDeleteClass int16 = 0x4007
+ OperationCupsAcceptJobs int16 = 0x4008
+ OperationCupsRejectJobs int16 = 0x4009
+ OperationCupsSetDefault int16 = 0x400a
+ OperationCupsGetDevices int16 = 0x400b
+ OperationCupsGetPPDs int16 = 0x400c
+ OperationCupsMoveJob int16 = 0x400d
+ OperationCupsAuthenticateJob int16 = 0x400e
+ OperationCupsGetPpd int16 = 0x400f
+ OperationCupsGetDocument int16 = 0x4027
+ OperationCupsCreateLocalPrinter int16 = 0x4028
+)
+
+// ipp tags
+const (
+ TagCupsInvalid int8 = -1
+ TagZero int8 = 0x00
+ TagOperation int8 = 0x01
+ TagJob int8 = 0x02
+ TagEnd int8 = 0x03
+ TagPrinter int8 = 0x04
+ TagUnsupportedGroup int8 = 0x05
+ TagSubscription int8 = 0x06
+ TagEventNotification int8 = 0x07
+ TagResource int8 = 0x08
+ TagDocument int8 = 0x09
+ TagSystem int8 = 0x0a
+ TagUnsupportedValue int8 = 0x10
+ TagDefault int8 = 0x11
+ TagUnknown int8 = 0x12
+ TagNoValue int8 = 0x13
+ TagNotSettable int8 = 0x15
+ TagDeleteAttr int8 = 0x16
+ TagAdminDefine int8 = 0x17
+ TagInteger int8 = 0x21
+ TagBoolean int8 = 0x22
+ TagEnum int8 = 0x23
+ TagString int8 = 0x30
+ TagDate int8 = 0x31
+ TagResolution int8 = 0x32
+ TagRange int8 = 0x33
+ TagBeginCollection int8 = 0x34
+ TagTextLang int8 = 0x35
+ TagNameLang int8 = 0x36
+ TagEndCollection int8 = 0x37
+ TagText int8 = 0x41
+ TagName int8 = 0x42
+ TagReservedString int8 = 0x43
+ TagKeyword int8 = 0x44
+ TagUri int8 = 0x45
+ TagUriScheme int8 = 0x46
+ TagCharset int8 = 0x47
+ TagLanguage int8 = 0x48
+ TagMimeType int8 = 0x49
+ TagMemberName int8 = 0x4a
+ TagExtension int8 = 0x7f
+)
+
+// job states
+const (
+ JobStatePending int8 = 0x03
+ JobStateHeld int8 = 0x04
+ JobStateProcessing int8 = 0x05
+ JobStateStopped int8 = 0x06
+ JobStateCanceled int8 = 0x07
+ JobStateAborted int8 = 0x08
+ JobStateCompleted int8 = 0x09
+)
+
+// document states
+const (
+ DocumentStatePending int8 = 0x03
+ DocumentStateProcessing int8 = 0x05
+ DocumentStateCanceled int8 = 0x07
+ DocumentStateAborted int8 = 0x08
+ DocumentStateCompleted int8 = 0x08
+)
+
+// printer states
+const (
+ PrinterStateIdle int8 = 0x0003
+ PrinterStateProcessing int8 = 0x0004
+ PrinterStateStopped int8 = 0x0005
+)
+
+// job state filter
+const (
+ JobStateFilterNotCompleted = "not-completed"
+ JobStateFilterCompleted = "completed"
+ JobStateFilterAll = "all"
+)
+
+// error policies
+const (
+ ErrorPolicyRetryJob = "retry-job"
+ ErrorPolicyAbortJob = "abort-job"
+ ErrorPolicyRetryCurrentJob = "retry-current-job"
+ ErrorPolicyStopPrinter = "stop-printer"
+)
+
+// ipp defaults
+const (
+ CharsetLanguage = "en-US"
+ Charset = "utf-8"
+ ProtocolVersionMajor = int8(2)
+ ProtocolVersionMinor = int8(0)
+
+ DefaultJobPriority = 50
+)
+
+// useful mime types for ipp
+const (
+ MimeTypePostscript = "application/postscript"
+ MimeTypeOctetStream = "application/octet-stream"
+)
+
+// ipp content types
+const (
+ ContentTypeIPP = "application/ipp"
+)
+
+// known ipp attributes
+const (
+ AttributeCopies = "copies"
+ AttributeDocumentFormat = "document-format"
+ AttributeDocumentName = "document-name"
+ AttributeJobID = "job-id"
+ AttributeJobName = "job-name"
+ AttributeJobPriority = "job-priority"
+ AttributeJobURI = "job-uri"
+ AttributeLastDocument = "last-document"
+ AttributeMyJobs = "my-jobs"
+ AttributePPDName = "ppd-name"
+ AttributePPDMakeAndModel = "ppd-make-and-model"
+ AttributePrinterIsShared = "printer-is-shared"
+ AttributePrinterIsTemporary = "printer-is-temporary"
+ AttributePrinterURI = "printer-uri"
+ AttributePurgeJobs = "purge-jobs"
+ AttributeRequestedAttributes = "requested-attributes"
+ AttributeRequestingUserName = "requesting-user-name"
+ AttributeWhichJobs = "which-jobs"
+ AttributeFirstJobID = "first-job-id"
+ AttributeLimit = "limit"
+ AttributeStatusMessage = "status-message"
+ AttributeCharset = "attributes-charset"
+ AttributeNaturalLanguage = "attributes-natural-language"
+ AttributeDeviceURI = "device-uri"
+ AttributeHoldJobUntil = "job-hold-until"
+ AttributePrinterErrorPolicy = "printer-error-policy"
+ AttributePrinterInfo = "printer-info"
+ AttributePrinterLocation = "printer-location"
+ AttributePrinterName = "printer-name"
+ AttributePrinterStateReasons = "printer-state-reasons"
+ AttributeJobPrinterURI = "job-printer-uri"
+ AttributeMemberURIs = "member-uris"
+ AttributeDocumentNumber = "document-number"
+ AttributeDocumentState = "document-state"
+ AttributeFinishings = "finishings"
+ AttributeJobHoldUntil = "hold-job-until"
+ AttributeJobSheets = "job-sheets"
+ AttributeJobState = "job-state"
+ AttributeJobStateReason = "job-state-reason"
+ AttributeMedia = "media"
+ AttributeSides = "sides"
+ AttributeNumberUp = "number-up"
+ AttributeOrientationRequested = "orientation-requested"
+ AttributePrintQuality = "print-quality"
+ AttributePrinterIsAcceptingJobs = "printer-is-accepting-jobs"
+ AttributePrinterResolution = "printer-resolution"
+ AttributePrinterState = "printer-state"
+ AttributeMemberNames = "member-names"
+ AttributePrinterType = "printer-type"
+ AttributePrinterMakeAndModel = "printer-make-and-model"
+ AttributePrinterStateMessage = "printer-state-message"
+ AttributePrinterUriSupported = "printer-uri-supported"
+ AttributeJobMediaProgress = "job-media-progress"
+ AttributeJobKilobyteOctets = "job-k-octets"
+ AttributeNumberOfDocuments = "number-of-documents"
+ AttributeJobOriginatingUserName = "job-originating-user-name"
+ AttributeOutputOrder = "outputorder"
+ AttributeJobStateReasons = "job-state-reasons"
+ AttributeJobStateMessage = "job-state-message"
+ AttributeJobPrinterStateReasons = "job-printer-state-reasons"
+ AttributeJobPrinterStateMessage = "job-printer-state-message"
+ AttributeJobImpressionsCompleted = "job-impressions-completed"
+ AttributePrintScaling = "print-scaling"
+)
+
+// Default attributes
+var (
+ DefaultClassAttributes = []string{AttributePrinterName, AttributeMemberNames}
+ DefaultPrinterAttributes = []string{
+ AttributePrinterName, AttributePrinterType, AttributePrinterLocation, AttributePrinterInfo,
+ AttributePrinterMakeAndModel, AttributePrinterState, AttributePrinterStateMessage, AttributePrinterStateReasons,
+ AttributePrinterUriSupported, AttributeDeviceURI, AttributePrinterIsShared,
+ }
+ DefaultJobAttributes = []string{
+ AttributeJobID, AttributeJobName, AttributePrinterURI, AttributeJobState, AttributeJobStateReason,
+ AttributeJobHoldUntil, AttributeJobMediaProgress, AttributeJobKilobyteOctets, AttributeNumberOfDocuments, AttributeCopies,
+ AttributeJobOriginatingUserName,
+ }
+)
+
+// Attribute to tag mapping
+var (
+ AttributeTagMapping = map[string]int8{
+ AttributeCharset: TagCharset,
+ AttributeNaturalLanguage: TagLanguage,
+ AttributeCopies: TagInteger,
+ AttributeDeviceURI: TagUri,
+ AttributeDocumentFormat: TagMimeType,
+ AttributeDocumentName: TagName,
+ AttributeDocumentNumber: TagInteger,
+ AttributeDocumentState: TagEnum,
+ AttributeFinishings: TagEnum,
+ AttributeJobHoldUntil: TagKeyword,
+ AttributeHoldJobUntil: TagKeyword,
+ AttributeJobID: TagInteger,
+ AttributeJobName: TagName,
+ AttributeJobPrinterURI: TagUri,
+ AttributeJobPriority: TagInteger,
+ AttributeJobSheets: TagName,
+ AttributeJobState: TagEnum,
+ AttributeJobStateReason: TagKeyword,
+ AttributeJobURI: TagUri,
+ AttributeLastDocument: TagBoolean,
+ AttributeMedia: TagKeyword,
+ AttributeSides: TagKeyword,
+ AttributeMemberURIs: TagUri,
+ AttributeMyJobs: TagBoolean,
+ AttributeNumberUp: TagInteger,
+ AttributeOrientationRequested: TagEnum,
+ AttributePPDName: TagName,
+ AttributePPDMakeAndModel: TagText,
+ AttributeNumberOfDocuments: TagInteger,
+ AttributePrintQuality: TagEnum,
+ AttributePrinterErrorPolicy: TagName,
+ AttributePrinterInfo: TagText,
+ AttributePrinterIsAcceptingJobs: TagBoolean,
+ AttributePrinterIsShared: TagBoolean,
+ AttributePrinterIsTemporary: TagBoolean,
+ AttributePrinterName: TagName,
+ AttributePrinterLocation: TagText,
+ AttributePrinterResolution: TagResolution,
+ AttributePrinterState: TagEnum,
+ AttributePrinterStateReasons: TagKeyword,
+ AttributePrinterURI: TagUri,
+ AttributePurgeJobs: TagBoolean,
+ AttributeRequestedAttributes: TagKeyword,
+ AttributeRequestingUserName: TagName,
+ AttributeWhichJobs: TagKeyword,
+ AttributeFirstJobID: TagInteger,
+ AttributeStatusMessage: TagText,
+ AttributeLimit: TagInteger,
+ AttributeOutputOrder: TagName,
+ AttributeJobStateReasons: TagString,
+ AttributeJobStateMessage: TagString,
+ AttributeJobPrinterStateReasons: TagString,
+ AttributeJobPrinterStateMessage: TagString,
+ AttributeJobImpressionsCompleted: TagInteger,
+ AttributePrintScaling: TagKeyword,
+ // IPP Subscription/Notification attributes (added for dankdots)
+ "notify-events": TagKeyword,
+ "notify-pull-method": TagKeyword,
+ "notify-lease-duration": TagInteger,
+ "notify-subscription-id": TagInteger,
+ "notify-subscription-ids": TagInteger,
+ "notify-sequence-numbers": TagInteger,
+ "notify-wait": TagBoolean,
+ "notify-recipient-uri": TagUri,
+ }
+)
diff --git a/backend/pkg/ipp/cups-client.go b/backend/pkg/ipp/cups-client.go
new file mode 100644
index 00000000..9e2efc33
--- /dev/null
+++ b/backend/pkg/ipp/cups-client.go
@@ -0,0 +1,322 @@
+package ipp
+
+import (
+ "bytes"
+ "strings"
+)
+
+// CUPSClient implements a ipp client with specific cups operations
+type CUPSClient struct {
+ *IPPClient
+}
+
+// NewCUPSClient creates a new cups ipp client (used HttpAdapter internally)
+func NewCUPSClient(host string, port int, username, password string, useTLS bool) *CUPSClient {
+ ippClient := NewIPPClient(host, port, username, password, useTLS)
+ return &CUPSClient{ippClient}
+}
+
+// NewCUPSClientWithAdapter creates a new cups ipp client with given Adapter
+func NewCUPSClientWithAdapter(username string, adapter Adapter) *CUPSClient {
+ ippClient := NewIPPClientWithAdapter(username, adapter)
+ return &CUPSClient{ippClient}
+}
+
+// GetDevices returns a map of device uris and printer attributes
+func (c *CUPSClient) GetDevices() (map[string]Attributes, error) {
+ req := NewRequest(OperationCupsGetDevices, 1)
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ printerNameMap := make(map[string]Attributes)
+
+ for _, printerAttributes := range resp.PrinterAttributes {
+ printerNameMap[printerAttributes[AttributeDeviceURI][0].Value.(string)] = printerAttributes
+ }
+
+ return printerNameMap, nil
+}
+
+// MoveJob moves a job to a other printer
+func (c *CUPSClient) MoveJob(jobID int, destPrinter string) error {
+ req := NewRequest(OperationCupsMoveJob, 1)
+ req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID)
+ req.PrinterAttributes[AttributeJobPrinterURI] = c.getPrinterUri(destPrinter)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil)
+ return err
+}
+
+// MoveAllJob moves all job from a printer to a other printer
+func (c *CUPSClient) MoveAllJob(srcPrinter, destPrinter string) error {
+ req := NewRequest(OperationCupsMoveJob, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(srcPrinter)
+ req.PrinterAttributes[AttributeJobPrinterURI] = c.getPrinterUri(destPrinter)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil)
+ return err
+}
+
+// GetPPDs returns a map of ppd names and attributes
+func (c *CUPSClient) GetPPDs() (map[string]Attributes, error) {
+ req := NewRequest(OperationCupsGetPPDs, 1)
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ ppdNameMap := make(map[string]Attributes)
+
+ for _, printerAttributes := range resp.PrinterAttributes {
+ ppdNameMap[printerAttributes[AttributePPDName][0].Value.(string)] = printerAttributes
+ }
+
+ return ppdNameMap, nil
+}
+
+// AcceptJobs lets a printer accept jobs again
+func (c *CUPSClient) AcceptJobs(printer string) error {
+ req := NewRequest(OperationCupsAcceptJobs, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// RejectJobs does not let a printer accept jobs
+func (c *CUPSClient) RejectJobs(printer string) error {
+ req := NewRequest(OperationCupsRejectJobs, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// AddPrinterToClass adds a printer to a class, if the class does not exists it will be crated
+func (c *CUPSClient) AddPrinterToClass(class, printer string) error {
+ attributes, err := c.GetPrinterAttributes(class, []string{AttributeMemberURIs})
+ if err != nil && !IsNotExistsError(err) {
+ return err
+ }
+
+ memberURIList := make([]string, 0)
+
+ if !IsNotExistsError(err) {
+ for _, member := range attributes[AttributeMemberURIs] {
+ memberString := strings.Split(member.Value.(string), "/")
+ printerName := memberString[len(memberString)-1]
+
+ if printerName == printer {
+ return nil
+ }
+
+ memberURIList = append(memberURIList, member.Value.(string))
+ }
+ }
+
+ memberURIList = append(memberURIList, c.getPrinterUri(printer))
+
+ req := NewRequest(OperationCupsAddModifyClass, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getClassUri(class)
+ req.PrinterAttributes[AttributeMemberURIs] = memberURIList
+
+ _, err = c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// DeletePrinterFromClass removes a printer from a class, if a class has no more printer it will be deleted
+func (c *CUPSClient) DeletePrinterFromClass(class, printer string) error {
+ attributes, err := c.GetPrinterAttributes(class, []string{AttributeMemberURIs})
+ if err != nil {
+ return err
+ }
+
+ memberURIList := make([]string, 0)
+
+ for _, member := range attributes[AttributeMemberURIs] {
+ memberString := strings.Split(member.Value.(string), "/")
+ printerName := memberString[len(memberString)-1]
+
+ if printerName != printer {
+ memberURIList = append(memberURIList, member.Value.(string))
+ }
+ }
+
+ if len(memberURIList) == 0 {
+ return c.DeleteClass(class)
+ }
+
+ req := NewRequest(OperationCupsAddModifyClass, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getClassUri(class)
+ req.PrinterAttributes[AttributeMemberURIs] = memberURIList
+
+ _, err = c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// DeleteClass deletes a class
+func (c *CUPSClient) DeleteClass(class string) error {
+ req := NewRequest(OperationCupsDeleteClass, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getClassUri(class)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// CreatePrinter creates a new printer
+func (c *CUPSClient) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy string, information, location string) error {
+ req := NewRequest(OperationCupsAddModifyPrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(name)
+ req.OperationAttributes[AttributePPDName] = ppd
+ req.OperationAttributes[AttributePrinterIsShared] = shared
+ req.PrinterAttributes[AttributePrinterStateReasons] = "none"
+ req.PrinterAttributes[AttributeDeviceURI] = deviceURI
+ req.PrinterAttributes[AttributePrinterInfo] = information
+ req.PrinterAttributes[AttributePrinterLocation] = location
+ req.PrinterAttributes[AttributePrinterErrorPolicy] = errorPolicy
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// SetPrinterPPD sets the ppd for a printer
+func (c *CUPSClient) SetPrinterPPD(printer, ppd string) error {
+ req := NewRequest(OperationCupsAddModifyPrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.OperationAttributes[AttributePPDName] = ppd
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// SetPrinterDeviceURI sets the device uri for a printer
+func (c *CUPSClient) SetPrinterDeviceURI(printer, deviceURI string) error {
+ req := NewRequest(OperationCupsAddModifyPrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.PrinterAttributes[AttributeDeviceURI] = deviceURI
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// SetPrinterIsShared shares or unshares a printer in the network
+func (c *CUPSClient) SetPrinterIsShared(printer string, shared bool) error {
+ req := NewRequest(OperationCupsAddModifyPrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.OperationAttributes[AttributePrinterIsShared] = shared
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// SetPrinterErrorPolicy sets the error policy for a printer
+func (c *CUPSClient) SetPrinterErrorPolicy(printer string, errorPolicy string) error {
+ req := NewRequest(OperationCupsAddModifyPrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.PrinterAttributes[AttributePrinterErrorPolicy] = errorPolicy
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// SetPrinterInformation sets general printer information
+func (c *CUPSClient) SetPrinterInformation(printer, information string) error {
+ req := NewRequest(OperationCupsAddModifyPrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.PrinterAttributes[AttributePrinterInfo] = information
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// SetPrinterLocation sets the printer location
+func (c *CUPSClient) SetPrinterLocation(printer, location string) error {
+ req := NewRequest(OperationCupsAddModifyPrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.PrinterAttributes[AttributePrinterLocation] = location
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// DeletePrinter deletes a printer
+func (c *CUPSClient) DeletePrinter(printer string) error {
+ req := NewRequest(OperationCupsDeletePrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// GetPrinters returns a map of printer names and attributes
+func (c *CUPSClient) GetPrinters(attributes []string) (map[string]Attributes, error) {
+ req := NewRequest(OperationCupsGetPrinters, 1)
+
+ if attributes == nil {
+ req.OperationAttributes[AttributeRequestedAttributes] = DefaultPrinterAttributes
+ } else {
+ req.OperationAttributes[AttributeRequestedAttributes] = append(attributes, AttributePrinterName)
+ }
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ printerNameMap := make(map[string]Attributes)
+
+ for _, printerAttributes := range resp.PrinterAttributes {
+ printerNameMap[printerAttributes[AttributePrinterName][0].Value.(string)] = printerAttributes
+ }
+
+ return printerNameMap, nil
+}
+
+// GetClasses returns a map of class names and attributes
+func (c *CUPSClient) GetClasses(attributes []string) (map[string]Attributes, error) {
+ req := NewRequest(OperationCupsGetClasses, 1)
+
+ if attributes == nil {
+ req.OperationAttributes[AttributeRequestedAttributes] = DefaultClassAttributes
+ } else {
+ req.OperationAttributes[AttributeRequestedAttributes] = append(attributes, AttributePrinterName)
+ }
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ printerNameMap := make(map[string]Attributes)
+
+ for _, printerAttributes := range resp.PrinterAttributes {
+ printerNameMap[printerAttributes[AttributePrinterName][0].Value.(string)] = printerAttributes
+ }
+
+ return printerNameMap, nil
+}
+
+// PrintTestPage prints a test page of type application/vnd.cups-pdf-banner
+func (c *CUPSClient) PrintTestPage(printer string) (int, error) {
+ testPage := new(bytes.Buffer)
+ testPage.WriteString("#PDF-BANNER\n")
+ testPage.WriteString("Template default-testpage.pdf\n")
+ testPage.WriteString("Show printer-name printer-info printer-location printer-make-and-model printer-driver-name")
+ testPage.WriteString("printer-driver-version paper-size imageable-area job-id options time-at-creation")
+ testPage.WriteString("time-at-processing\n\n")
+
+ return c.PrintDocuments([]Document{
+ {
+ Document: testPage,
+ Name: "Test Page",
+ Size: testPage.Len(),
+ MimeType: MimeTypePostscript,
+ },
+ }, printer, map[string]interface{}{
+ AttributeJobName: "Test Page",
+ })
+}
diff --git a/backend/pkg/ipp/error.go b/backend/pkg/ipp/error.go
new file mode 100644
index 00000000..e4edf5e7
--- /dev/null
+++ b/backend/pkg/ipp/error.go
@@ -0,0 +1,31 @@
+package ipp
+
+import "fmt"
+
+// IsNotExistsError checks a given error whether a printer or class does not exist
+func IsNotExistsError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ return err.Error() == "The printer or class does not exist."
+}
+
+// IPPError used for non ok ipp status codes
+type IPPError struct {
+ Status int16
+ Message string
+}
+
+func (e IPPError) Error() string {
+ return fmt.Sprintf("ipp status: %d, message: %s", e.Status, e.Message)
+}
+
+// HTTPError used for non 200 http codes
+type HTTPError struct {
+ Code int
+}
+
+func (e HTTPError) Error() string {
+ return fmt.Sprintf("got http code %d", e.Code)
+}
diff --git a/backend/pkg/ipp/ipp-client.go b/backend/pkg/ipp/ipp-client.go
new file mode 100644
index 00000000..7a7cc532
--- /dev/null
+++ b/backend/pkg/ipp/ipp-client.go
@@ -0,0 +1,329 @@
+package ipp
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path"
+)
+
+// Document wraps an io.Reader with more information, needed for encoding
+type Document struct {
+ Document io.Reader
+ Size int
+ Name string
+ MimeType string
+}
+
+// IPPClient implements a generic ipp client
+type IPPClient struct {
+ username string
+ adapter Adapter
+}
+
+// NewIPPClient creates a new generic ipp client (used HttpAdapter internally)
+func NewIPPClient(host string, port int, username, password string, useTLS bool) *IPPClient {
+ adapter := NewHttpAdapter(host, port, username, password, useTLS)
+
+ return &IPPClient{
+ username: username,
+ adapter: adapter,
+ }
+}
+
+// NewIPPClientWithAdapter creates a new generic ipp client with given Adapter
+func NewIPPClientWithAdapter(username string, adapter Adapter) *IPPClient {
+ return &IPPClient{
+ username: username,
+ adapter: adapter,
+ }
+}
+
+func (c *IPPClient) getPrinterUri(printer string) string {
+ return fmt.Sprintf("ipp://localhost/printers/%s", printer)
+}
+
+func (c *IPPClient) getJobUri(jobID int) string {
+ return fmt.Sprintf("ipp://localhost/jobs/%d", jobID)
+}
+
+func (c *IPPClient) getClassUri(printer string) string {
+ return fmt.Sprintf("ipp://localhost/classes/%s", printer)
+}
+
+// SendRequest sends a request to a remote uri end returns the response
+func (c *IPPClient) SendRequest(url string, req *Request, additionalResponseData io.Writer) (*Response, error) {
+ if _, ok := req.OperationAttributes[AttributeRequestingUserName]; !ok {
+ req.OperationAttributes[AttributeRequestingUserName] = c.username
+ }
+
+ return c.adapter.SendRequest(url, req, additionalResponseData)
+}
+
+// PrintDocuments prints one or more documents using a Create-Job operation followed by one or more Send-Document operation(s). custom job settings can be specified via the jobAttributes parameter
+func (c *IPPClient) PrintDocuments(docs []Document, printer string, jobAttributes map[string]interface{}) (int, error) {
+ printerURI := c.getPrinterUri(printer)
+
+ req := NewRequest(OperationCreateJob, 1)
+ req.OperationAttributes[AttributePrinterURI] = printerURI
+ req.OperationAttributes[AttributeRequestingUserName] = c.username
+
+ // set defaults for some attributes, may get overwritten
+ req.OperationAttributes[AttributeJobName] = docs[0].Name
+ req.OperationAttributes[AttributeCopies] = 1
+ req.OperationAttributes[AttributeJobPriority] = DefaultJobPriority
+
+ for key, value := range jobAttributes {
+ req.JobAttributes[key] = value
+ }
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil)
+ if err != nil {
+ return -1, err
+ }
+
+ if len(resp.JobAttributes) == 0 {
+ return 0, errors.New("server doesn't returned a job id")
+ }
+
+ jobID := resp.JobAttributes[0][AttributeJobID][0].Value.(int)
+
+ documentCount := len(docs) - 1
+
+ for docID, doc := range docs {
+ req = NewRequest(OperationSendDocument, 2)
+ req.OperationAttributes[AttributePrinterURI] = printerURI
+ req.OperationAttributes[AttributeRequestingUserName] = c.username
+ req.OperationAttributes[AttributeJobID] = jobID
+ req.OperationAttributes[AttributeDocumentName] = doc.Name
+ req.OperationAttributes[AttributeDocumentFormat] = doc.MimeType
+ req.OperationAttributes[AttributeLastDocument] = docID == documentCount
+ req.File = doc.Document
+ req.FileSize = doc.Size
+
+ _, err = c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil)
+ if err != nil {
+ return -1, err
+ }
+ }
+
+ return jobID, nil
+}
+
+// PrintJob prints a document using a Print-Job operation. custom job settings can be specified via the jobAttributes parameter
+func (c *IPPClient) PrintJob(doc Document, printer string, jobAttributes map[string]interface{}) (int, error) {
+ printerURI := c.getPrinterUri(printer)
+
+ req := NewRequest(OperationPrintJob, 1)
+ req.OperationAttributes[AttributePrinterURI] = printerURI
+ req.OperationAttributes[AttributeRequestingUserName] = c.username
+ req.OperationAttributes[AttributeJobName] = doc.Name
+ req.OperationAttributes[AttributeDocumentFormat] = doc.MimeType
+
+ // set defaults for some attributes, may get overwritten
+ req.OperationAttributes[AttributeCopies] = 1
+ req.OperationAttributes[AttributeJobPriority] = DefaultJobPriority
+
+ for key, value := range jobAttributes {
+ req.JobAttributes[key] = value
+ }
+
+ req.File = doc.Document
+ req.FileSize = doc.Size
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil)
+ if err != nil {
+ return -1, err
+ }
+
+ if len(resp.JobAttributes) == 0 {
+ return 0, errors.New("server doesn't returned a job id")
+ }
+
+ jobID := resp.JobAttributes[0][AttributeJobID][0].Value.(int)
+
+ return jobID, nil
+}
+
+// PrintFile prints a local file on the file system. custom job settings can be specified via the jobAttributes parameter
+func (c *IPPClient) PrintFile(filePath, printer string, jobAttributes map[string]interface{}) (int, error) {
+ fileStats, err := os.Stat(filePath)
+ if os.IsNotExist(err) {
+ return -1, err
+ }
+
+ fileName := path.Base(filePath)
+
+ document, err := os.Open(filePath)
+ if err != nil {
+ return 0, err
+ }
+ defer document.Close()
+
+ jobAttributes[AttributeJobName] = fileName
+
+ return c.PrintDocuments([]Document{
+ {
+ Document: document,
+ Name: fileName,
+ Size: int(fileStats.Size()),
+ MimeType: MimeTypeOctetStream,
+ },
+ }, printer, jobAttributes)
+}
+
+// GetPrinterAttributes returns the requested attributes for the specified printer, if attributes is nil the default attributes will be used
+func (c *IPPClient) GetPrinterAttributes(printer string, attributes []string) (Attributes, error) {
+ req := NewRequest(OperationGetPrinterAttributes, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.OperationAttributes[AttributeRequestingUserName] = c.username
+
+ if attributes == nil {
+ req.OperationAttributes[AttributeRequestedAttributes] = DefaultPrinterAttributes
+ } else {
+ req.OperationAttributes[AttributeRequestedAttributes] = attributes
+ }
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(resp.PrinterAttributes) == 0 {
+ return nil, errors.New("server doesn't return any printer attributes")
+ }
+
+ return resp.PrinterAttributes[0], nil
+}
+
+// ResumePrinter resumes a printer
+func (c *IPPClient) ResumePrinter(printer string) error {
+ req := NewRequest(OperationResumePrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// PausePrinter pauses a printer
+func (c *IPPClient) PausePrinter(printer string) error {
+ req := NewRequest(OperationPausePrinter, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// GetJobAttributes returns the requested attributes for the specified job, if attributes is nil the default job will be used
+func (c *IPPClient) GetJobAttributes(jobID int, attributes []string) (Attributes, error) {
+ req := NewRequest(OperationGetJobAttributes, 1)
+ req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID)
+
+ if attributes == nil {
+ req.OperationAttributes[AttributeRequestedAttributes] = DefaultJobAttributes
+ } else {
+ req.OperationAttributes[AttributeRequestedAttributes] = attributes
+ }
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("jobs", jobID), req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(resp.JobAttributes) == 0 {
+ return nil, errors.New("server doesn't return any job attributes")
+ }
+
+ return resp.JobAttributes[0], nil
+}
+
+// GetJobs returns jobs from a printer or class
+func (c *IPPClient) GetJobs(printer, class string, whichJobs string, myJobs bool, firstJobId, limit int, attributes []string) (map[int]Attributes, error) {
+ req := NewRequest(OperationGetJobs, 1)
+ req.OperationAttributes[AttributeWhichJobs] = whichJobs
+ req.OperationAttributes[AttributeMyJobs] = myJobs
+
+ if printer != "" {
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ } else if class != "" {
+ req.OperationAttributes[AttributePrinterURI] = c.getClassUri(printer)
+ } else {
+ req.OperationAttributes[AttributePrinterURI] = "ipp://localhost/"
+ }
+
+ if firstJobId > 0 {
+ req.OperationAttributes[AttributeFirstJobID] = firstJobId
+ }
+
+ if limit > 0 {
+ req.OperationAttributes[AttributeLimit] = limit
+ }
+
+ if myJobs {
+ req.OperationAttributes[AttributeRequestingUserName] = c.username
+ }
+
+ if attributes == nil {
+ req.OperationAttributes[AttributeRequestedAttributes] = DefaultJobAttributes
+ } else {
+ req.OperationAttributes[AttributeRequestedAttributes] = append(attributes, AttributeJobID)
+ }
+
+ resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ jobIDMap := make(map[int]Attributes)
+
+ for _, jobAttributes := range resp.JobAttributes {
+ jobIDMap[jobAttributes[AttributeJobID][0].Value.(int)] = jobAttributes
+ }
+
+ return jobIDMap, nil
+}
+
+// CancelJob cancels a job. if purge is true, the job will also be removed
+func (c *IPPClient) CancelJob(jobID int, purge bool) error {
+ req := NewRequest(OperationCancelJob, 1)
+ req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID)
+ req.OperationAttributes[AttributePurgeJobs] = purge
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil)
+ return err
+}
+
+// CancelAllJob cancels all jobs for a specified printer. if purge is true, the jobs will also be removed
+func (c *IPPClient) CancelAllJob(printer string, purge bool) error {
+ req := NewRequest(OperationCancelJobs, 1)
+ req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer)
+ req.OperationAttributes[AttributePurgeJobs] = purge
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil)
+ return err
+}
+
+// RestartJob restarts a job
+func (c *IPPClient) RestartJob(jobID int) error {
+ req := NewRequest(OperationRestartJob, 1)
+ req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID)
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil)
+ return err
+}
+
+// HoldJobUntil holds a job
+func (c *IPPClient) HoldJobUntil(jobID int, holdUntil string) error {
+ req := NewRequest(OperationRestartJob, 1)
+ req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID)
+ req.JobAttributes[AttributeHoldJobUntil] = holdUntil
+
+ _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil)
+ return err
+}
+
+// TestConnection tests if a tcp connection to the remote server is possible
+func (c *IPPClient) TestConnection() error {
+ return c.adapter.TestConnection()
+}
diff --git a/backend/pkg/ipp/request.go b/backend/pkg/ipp/request.go
new file mode 100644
index 00000000..0c296558
--- /dev/null
+++ b/backend/pkg/ipp/request.go
@@ -0,0 +1,299 @@
+package ipp
+
+import (
+ "bytes"
+ "encoding/binary"
+ "io"
+)
+
+// Request defines a ipp request
+type Request struct {
+ ProtocolVersionMajor int8
+ ProtocolVersionMinor int8
+
+ Operation int16
+ RequestId int32
+
+ OperationAttributes map[string]interface{}
+ JobAttributes map[string]interface{}
+ PrinterAttributes map[string]interface{}
+ SubscriptionAttributes map[string]interface{} // Added for subscription operations
+
+ File io.Reader
+ FileSize int
+}
+
+// NewRequest creates a new ipp request
+func NewRequest(op int16, reqID int32) *Request {
+ return &Request{
+ ProtocolVersionMajor: ProtocolVersionMajor,
+ ProtocolVersionMinor: ProtocolVersionMinor,
+ Operation: op,
+ RequestId: reqID,
+ OperationAttributes: make(map[string]interface{}),
+ JobAttributes: make(map[string]interface{}),
+ PrinterAttributes: make(map[string]interface{}),
+ SubscriptionAttributes: make(map[string]interface{}),
+ File: nil,
+ FileSize: -1,
+ }
+}
+
+// Encode encodes the request to a byte slice
+func (r *Request) Encode() ([]byte, error) {
+ buf := new(bytes.Buffer)
+ enc := NewAttributeEncoder(buf)
+
+ if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMajor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMinor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, r.Operation); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, r.RequestId); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, TagOperation); err != nil {
+ return nil, err
+ }
+
+ if r.OperationAttributes == nil {
+ r.OperationAttributes = make(map[string]interface{}, 2)
+ }
+
+ if _, found := r.OperationAttributes[AttributeCharset]; !found {
+ r.OperationAttributes[AttributeCharset] = Charset
+ }
+
+ if _, found := r.OperationAttributes[AttributeNaturalLanguage]; !found {
+ r.OperationAttributes[AttributeNaturalLanguage] = CharsetLanguage
+ }
+
+ if err := r.encodeOperationAttributes(enc); err != nil {
+ return nil, err
+ }
+
+ if len(r.JobAttributes) > 0 {
+ if err := binary.Write(buf, binary.BigEndian, TagJob); err != nil {
+ return nil, err
+ }
+ for attr, value := range r.JobAttributes {
+ if err := enc.Encode(attr, value); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if len(r.PrinterAttributes) > 0 {
+ if err := binary.Write(buf, binary.BigEndian, TagPrinter); err != nil {
+ return nil, err
+ }
+ for attr, value := range r.PrinterAttributes {
+ if err := enc.Encode(attr, value); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if len(r.SubscriptionAttributes) > 0 {
+ if err := binary.Write(buf, binary.BigEndian, TagSubscription); err != nil {
+ return nil, err
+ }
+ if err := r.encodeSubscriptionAttributes(enc); err != nil {
+ return nil, err
+ }
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, TagEnd); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+func (r *Request) encodeOperationAttributes(enc *AttributeEncoder) error {
+ ordered := []string{
+ AttributeCharset,
+ AttributeNaturalLanguage,
+ AttributePrinterURI,
+ AttributeJobID,
+ }
+
+ for _, attr := range ordered {
+ if value, ok := r.OperationAttributes[attr]; ok {
+ delete(r.OperationAttributes, attr)
+ if err := enc.Encode(attr, value); err != nil {
+ return err
+ }
+ }
+ }
+
+ for attr, value := range r.OperationAttributes {
+ if err := enc.Encode(attr, value); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (r *Request) encodeSubscriptionAttributes(enc *AttributeEncoder) error {
+ // Encode subscription attributes in proper order
+ // notify-pull-method and notify-lease-duration must come before notify-events
+ ordered := []string{
+ "notify-pull-method",
+ "notify-lease-duration",
+ "notify-events",
+ }
+
+ for _, attr := range ordered {
+ if value, ok := r.SubscriptionAttributes[attr]; ok {
+ delete(r.SubscriptionAttributes, attr)
+ if err := enc.Encode(attr, value); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Encode any remaining subscription attributes
+ for attr, value := range r.SubscriptionAttributes {
+ if err := enc.Encode(attr, value); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// RequestDecoder reads and decodes a request from a stream
+type RequestDecoder struct {
+ reader io.Reader
+}
+
+// NewRequestDecoder returns a new decoder that reads from r
+func NewRequestDecoder(r io.Reader) *RequestDecoder {
+ return &RequestDecoder{
+ reader: r,
+ }
+}
+
+// Decode decodes a ipp request into a request struct. additional data will be written to an io.Writer if data is not nil
+func (d *RequestDecoder) Decode(data io.Writer) (*Request, error) {
+ req := new(Request)
+
+ if err := binary.Read(d.reader, binary.BigEndian, &req.ProtocolVersionMajor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Read(d.reader, binary.BigEndian, &req.ProtocolVersionMinor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Read(d.reader, binary.BigEndian, &req.Operation); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Read(d.reader, binary.BigEndian, &req.RequestId); err != nil {
+ return nil, err
+ }
+
+ startByteSlice := make([]byte, 1)
+
+ tag := TagCupsInvalid
+ previousAttributeName := ""
+ tagSet := false
+
+ attribDecoder := NewAttributeDecoder(d.reader)
+
+ // decode attribute buffer
+ for {
+ if _, err := d.reader.Read(startByteSlice); err != nil {
+ // when we read from a stream, we may get an EOF if we want to read the end tag
+ // all data should be read and we can ignore the error
+ if err == io.EOF {
+ break
+ }
+ return nil, err
+ }
+
+ startByte := int8(startByteSlice[0])
+
+ // check if attributes are completed
+ if startByte == TagEnd {
+ break
+ }
+
+ if startByte == TagOperation {
+ if req.OperationAttributes == nil {
+ req.OperationAttributes = make(map[string]interface{})
+ }
+
+ tag = TagOperation
+ tagSet = true
+
+ }
+
+ if startByte == TagJob {
+ if req.JobAttributes == nil {
+ req.JobAttributes = make(map[string]interface{})
+ }
+ tag = TagJob
+ tagSet = true
+ }
+
+ if startByte == TagPrinter {
+ if req.PrinterAttributes == nil {
+ req.PrinterAttributes = make(map[string]interface{})
+ }
+ tag = TagPrinter
+ tagSet = true
+ }
+
+ if tagSet {
+ if _, err := d.reader.Read(startByteSlice); err != nil {
+ return nil, err
+ }
+ startByte = int8(startByteSlice[0])
+ }
+
+ attrib, err := attribDecoder.Decode(startByte)
+ if err != nil {
+ return nil, err
+ }
+
+ if attrib.Name != "" {
+ appendAttributeToRequest(req, tag, attrib.Name, attrib.Value)
+ previousAttributeName = attrib.Name
+ } else {
+ appendAttributeToRequest(req, tag, previousAttributeName, attrib.Value)
+ }
+
+ tagSet = false
+ }
+
+ if data != nil {
+ if _, err := io.Copy(data, d.reader); err != nil {
+ return nil, err
+ }
+ }
+
+ return req, nil
+}
+
+func appendAttributeToRequest(req *Request, tag int8, name string, value interface{}) {
+ switch tag {
+ case TagOperation:
+ req.OperationAttributes[name] = value
+ case TagPrinter:
+ req.PrinterAttributes[name] = value
+ case TagJob:
+ req.JobAttributes[name] = value
+ }
+}
diff --git a/backend/pkg/ipp/response.go b/backend/pkg/ipp/response.go
new file mode 100644
index 00000000..73e3566e
--- /dev/null
+++ b/backend/pkg/ipp/response.go
@@ -0,0 +1,383 @@
+package ipp
+
+import (
+ "bytes"
+ "encoding/binary"
+ "io"
+)
+
+// Attributes is a wrapper for a set of attributes
+type Attributes map[string][]Attribute
+
+// Response defines a ipp response
+type Response struct {
+ ProtocolVersionMajor int8
+ ProtocolVersionMinor int8
+
+ StatusCode int16
+ RequestId int32
+
+ OperationAttributes Attributes
+ PrinterAttributes []Attributes
+ JobAttributes []Attributes
+ SubscriptionAttributes []Attributes // Added for subscription responses
+}
+
+// CheckForErrors checks the status code and returns a error if it is not zero. it also returns the status message if provided by the server
+func (r *Response) CheckForErrors() error {
+ if r.StatusCode != StatusOk {
+ err := IPPError{
+ Status: r.StatusCode,
+ Message: "no status message returned",
+ }
+
+ if len(r.OperationAttributes["status-message"]) > 0 {
+ err.Message = r.OperationAttributes["status-message"][0].Value.(string)
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+// NewResponse creates a new ipp response
+func NewResponse(statusCode int16, reqID int32) *Response {
+ return &Response{
+ ProtocolVersionMajor: ProtocolVersionMajor,
+ ProtocolVersionMinor: ProtocolVersionMinor,
+ StatusCode: statusCode,
+ RequestId: reqID,
+ OperationAttributes: make(Attributes),
+ PrinterAttributes: make([]Attributes, 0),
+ JobAttributes: make([]Attributes, 0),
+ }
+}
+
+// Encode encodes the response to a byte slice
+func (r *Response) Encode() ([]byte, error) {
+ buf := new(bytes.Buffer)
+ enc := NewAttributeEncoder(buf)
+
+ if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMajor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMinor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, r.StatusCode); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, r.RequestId); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, TagOperation); err != nil {
+ return nil, err
+ }
+
+ if r.OperationAttributes == nil {
+ r.OperationAttributes = make(Attributes, 0)
+ }
+
+ if _, found := r.OperationAttributes[AttributeCharset]; !found {
+ r.OperationAttributes[AttributeCharset] = []Attribute{
+ {
+ Value: Charset,
+ },
+ }
+ }
+
+ if _, found := r.OperationAttributes[AttributeNaturalLanguage]; !found {
+ r.OperationAttributes[AttributeNaturalLanguage] = []Attribute{
+ {
+ Value: CharsetLanguage,
+ },
+ }
+ }
+
+ if err := r.encodeOperationAttributes(enc); err != nil {
+ return nil, err
+ }
+
+ if len(r.PrinterAttributes) > 0 {
+ for _, printerAttr := range r.PrinterAttributes {
+ if err := binary.Write(buf, binary.BigEndian, TagPrinter); err != nil {
+ return nil, err
+ }
+
+ for name, attr := range printerAttr {
+ if len(attr) == 0 {
+ continue
+ }
+
+ values := make([]interface{}, len(attr))
+ for i, v := range attr {
+ values[i] = v.Value
+ }
+
+ if len(values) == 1 {
+ if err := enc.Encode(name, values[0]); err != nil {
+ return nil, err
+ }
+ } else {
+ if err := enc.Encode(name, values); err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
+ }
+
+ if len(r.JobAttributes) > 0 {
+ for _, jobAttr := range r.JobAttributes {
+ if err := binary.Write(buf, binary.BigEndian, TagJob); err != nil {
+ return nil, err
+ }
+
+ for name, attr := range jobAttr {
+ if len(attr) == 0 {
+ continue
+ }
+
+ values := make([]interface{}, len(attr))
+ for i, v := range attr {
+ values[i] = v.Value
+ }
+
+ if len(values) == 1 {
+ if err := enc.Encode(name, values[0]); err != nil {
+ return nil, err
+ }
+ } else {
+ if err := enc.Encode(name, values); err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
+ }
+
+ if err := binary.Write(buf, binary.BigEndian, TagEnd); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+func (r *Response) encodeOperationAttributes(enc *AttributeEncoder) error {
+ ordered := []string{
+ AttributeCharset,
+ AttributeNaturalLanguage,
+ AttributePrinterURI,
+ AttributeJobID,
+ }
+
+ for _, name := range ordered {
+ if attr, ok := r.OperationAttributes[name]; ok {
+ delete(r.OperationAttributes, name)
+ if err := encodeOperationAttribute(enc, name, attr); err != nil {
+ return err
+ }
+ }
+ }
+
+ for name, attr := range r.OperationAttributes {
+ if err := encodeOperationAttribute(enc, name, attr); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func encodeOperationAttribute(enc *AttributeEncoder, name string, attr []Attribute) error {
+ if len(attr) == 0 {
+ return nil
+ }
+
+ values := make([]interface{}, len(attr))
+ for i, v := range attr {
+ values[i] = v.Value
+ }
+
+ if len(values) == 1 {
+ return enc.Encode(name, values[0])
+ }
+
+ return enc.Encode(name, values)
+}
+
+// ResponseDecoder reads and decodes a response from a stream
+type ResponseDecoder struct {
+ reader io.Reader
+}
+
+// NewResponseDecoder returns a new decoder that reads from r
+func NewResponseDecoder(r io.Reader) *ResponseDecoder {
+ return &ResponseDecoder{
+ reader: r,
+ }
+}
+
+// Decode decodes a ipp response into a response struct. additional data will be written to an io.Writer if data is not nil
+func (d *ResponseDecoder) Decode(data io.Writer) (*Response, error) {
+ /*
+ 1 byte: Protocol Major Version - b
+ 1 byte: Protocol Minor Version - b
+ 2 byte: Status ID - h
+ 4 byte: Request ID - i
+ 1 byte: Operation Attribute Byte (\0x01)
+ N times: Attributes
+ 1 byte: Attribute End Byte (\0x03)
+ */
+
+ resp := new(Response)
+
+ // wrap the reader so we have more functionality
+ // reader := bufio.NewReader(d.reader)
+
+ if err := binary.Read(d.reader, binary.BigEndian, &resp.ProtocolVersionMajor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Read(d.reader, binary.BigEndian, &resp.ProtocolVersionMinor); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Read(d.reader, binary.BigEndian, &resp.StatusCode); err != nil {
+ return nil, err
+ }
+
+ if err := binary.Read(d.reader, binary.BigEndian, &resp.RequestId); err != nil {
+ return nil, err
+ }
+
+ startByteSlice := make([]byte, 1)
+
+ tag := TagCupsInvalid
+ previousAttributeName := ""
+ tempAttributes := make(Attributes)
+ tagSet := false
+
+ attribDecoder := NewAttributeDecoder(d.reader)
+
+ // decode attribute buffer
+ for {
+ if _, err := d.reader.Read(startByteSlice); err != nil {
+ // when we read from a stream, we may get an EOF if we want to read the end tag
+ // all data should be read and we can ignore the error
+ if err == io.EOF {
+ break
+ }
+ return nil, err
+ }
+
+ startByte := int8(startByteSlice[0])
+
+ // check if attributes are completed
+ if startByte == TagEnd {
+ break
+ }
+
+ if startByte == TagOperation {
+ if len(tempAttributes) > 0 && tag != TagCupsInvalid {
+ appendAttributeToResponse(resp, tag, tempAttributes)
+ tempAttributes = make(Attributes)
+ }
+
+ tag = TagOperation
+ tagSet = true
+ }
+
+ if startByte == TagJob {
+ if len(tempAttributes) > 0 && tag != TagCupsInvalid {
+ appendAttributeToResponse(resp, tag, tempAttributes)
+ tempAttributes = make(Attributes)
+ }
+
+ tag = TagJob
+ tagSet = true
+ }
+
+ if startByte == TagPrinter {
+ if len(tempAttributes) > 0 && tag != TagCupsInvalid {
+ appendAttributeToResponse(resp, tag, tempAttributes)
+ tempAttributes = make(Attributes)
+ }
+
+ tag = TagPrinter
+ tagSet = true
+ }
+
+ if startByte == TagSubscription {
+ if len(tempAttributes) > 0 && tag != TagCupsInvalid {
+ appendAttributeToResponse(resp, tag, tempAttributes)
+ tempAttributes = make(Attributes)
+ }
+
+ tag = TagSubscription
+ tagSet = true
+ }
+
+ if startByte == TagEventNotification {
+ if len(tempAttributes) > 0 && tag != TagCupsInvalid {
+ appendAttributeToResponse(resp, tag, tempAttributes)
+ tempAttributes = make(Attributes)
+ }
+
+ tag = TagEventNotification
+ tagSet = true
+ }
+
+ if tagSet {
+ if _, err := d.reader.Read(startByteSlice); err != nil {
+ return nil, err
+ }
+ startByte = int8(startByteSlice[0])
+ }
+
+ attrib, err := attribDecoder.Decode(startByte)
+ if err != nil {
+ return nil, err
+ }
+
+ if attrib.Name != "" {
+ tempAttributes[attrib.Name] = append(tempAttributes[attrib.Name], *attrib)
+ previousAttributeName = attrib.Name
+ } else {
+ tempAttributes[previousAttributeName] = append(tempAttributes[previousAttributeName], *attrib)
+ }
+
+ tagSet = false
+ }
+
+ if len(tempAttributes) > 0 && tag != TagCupsInvalid {
+ appendAttributeToResponse(resp, tag, tempAttributes)
+ }
+
+ if data != nil {
+ if _, err := io.Copy(data, d.reader); err != nil {
+ return nil, err
+ }
+ }
+
+ return resp, nil
+}
+
+func appendAttributeToResponse(resp *Response, tag int8, attr map[string][]Attribute) {
+ switch tag {
+ case TagOperation:
+ resp.OperationAttributes = attr
+ case TagPrinter:
+ resp.PrinterAttributes = append(resp.PrinterAttributes, attr)
+ case TagJob:
+ resp.JobAttributes = append(resp.JobAttributes, attr)
+ case TagSubscription, TagEventNotification:
+ // Both subscription and event notification attributes go to SubscriptionAttributes
+ resp.SubscriptionAttributes = append(resp.SubscriptionAttributes, attr)
+ }
+}
diff --git a/backend/pkg/ipp/utils.go b/backend/pkg/ipp/utils.go
new file mode 100644
index 00000000..62689a3b
--- /dev/null
+++ b/backend/pkg/ipp/utils.go
@@ -0,0 +1,28 @@
+package ipp
+
+import (
+ "fmt"
+ "os"
+ "path"
+)
+
+// ParseControlFile reads and decodes a cups control file into a response
+func ParseControlFile(jobID int, spoolDirectory string) (*Response, error) {
+ if spoolDirectory == "" {
+ spoolDirectory = "/var/spool/cups"
+ }
+
+ controlFilePath := path.Join(spoolDirectory, fmt.Sprintf("c%d", jobID))
+
+ if _, err := os.Stat(controlFilePath); err != nil {
+ return nil, err
+ }
+
+ controlFile, err := os.Open(controlFilePath)
+ if err != nil {
+ return nil, err
+ }
+ defer controlFile.Close()
+
+ return NewResponseDecoder(controlFile).Decode(nil)
+}
diff --git a/CLAUDE.md b/quickshell/CLAUDE.md
similarity index 100%
rename from CLAUDE.md
rename to quickshell/CLAUDE.md
diff --git a/quickshell/CONTRIBUTORS.md b/quickshell/CONTRIBUTORS.md
new file mode 100644
index 00000000..d7e85463
--- /dev/null
+++ b/quickshell/CONTRIBUTORS.md
@@ -0,0 +1,199 @@
+# Contributors to DankMaterialShell
+
+This document lists all significant contributors to DankMaterialShell who hold copyright over their contributions. For the purpose of license changes, consent is required from contributors listed below.
+
+**Note**: Contributors with only trivial changes (whitespace, minor documentation, <50 lines) are not listed here, as such changes typically do not meet the threshold of originality for copyright.
+
+---
+
+## Primary Authors
+
+These individuals have made extensive contributions (10,000+ lines of code):
+
+1. **bbedward** (BB)
+ - Email: bbedward@gmail.com
+ - Contributions: 481,676 lines QML, 97,760 lines other code
+ - Commits: 1,216 commits
+ - Role: Primary author and maintainer
+
+2. **purian23**
+ - Email: purian23@gmail.com / Purian23@gmail.com
+ - Contributions: 54,791 lines QML, 1,541 lines other code
+ - Commits: 237 commits
+ - Role: Major contributor
+
+---
+
+## Major Contributors
+
+These individuals have made substantial contributions (1,000+ lines of code OR 10+ commits):
+
+3. **Bruno Cesar Rocha**
+ - Email: rochacbruno@users.noreply.github.com, rochacbruno@gmail.com
+ - Contributions: 2,969 lines QML, 183 lines other code
+ - Commits: 11 commits
+
+4. **Massimo Branchini** (max72bra)
+ - Email: 45419961+max72bra@users.noreply.github.com
+ - Contributions: 2,088 lines QML, 717 lines other code
+ - Commits: 20 commits
+
+5. **Jon Rogers**
+ - Email: 67245+devnullvoid@users.noreply.github.com
+ - Contributions: 1,512 lines QML
+ - Commits: 13 commits
+
+6. **Aziz Hasanain**
+ - Email: sgtaziz013@gmail.com
+ - Contributions: 1,290 lines QML, 122 lines other code
+ - Commits: 5 commits
+
+7. **Aleksandr Lebedev** (KyleKrein)
+ - Email: 50716293+KyleKrein@users.noreply.github.com, alex.lebedev2003@icloud.com
+ - Contributions: 997 lines QML
+ - Commits: 11 commits
+
+8. **Mattias**
+ - Email: 810218+avesst@users.noreply.github.com
+ - Contributions: 763 lines QML
+ - Commits: 4 commits
+
+9. **bokicoder**
+ - Email: 1556588440@qq.com
+ - Contributions: Configuration and build system (96 lines config)
+ - Commits: 14 commits
+
+---
+
+## Significant Contributors
+
+These individuals have made notable contributions (100+ lines of code):
+
+10. **Oleksandr**
+ - Email: 94455603+avktech78@users.noreply.github.com
+ - Contributions: 439 lines QML
+ - Commits: 3 commits
+
+11. **lonerorz**
+ - Email: 68736947+lonerOrz@users.noreply.github.com
+ - Contributions: 301 lines QML
+ - Commits: 1 commit
+
+12. **Eduardo B. A.** (sezaru)
+ - Email: 279828+sezaru@users.noreply.github.com
+ - Contributions: 304 lines config/theme files
+ - Commits: 5 commits
+
+13. **blue linden**
+ - Email: dev@bluelinden.art
+ - Contributions: 254 lines config/theme files
+ - Commits: 5 commits
+
+14. **Rishi Vora**
+ - Email: vorarishi22+github@gmail.com
+ - Contributions: 253 lines config/theme files
+ - Commits: 3 commits
+
+15. **asaadmohammed74**
+ - Email: asaadmohammed74@gmail.com
+ - Contributions: 251 lines QML
+ - Commits: 2 commits
+
+16. **Parthiv Seetharaman**
+ - Email: parthivs@myrdd.info
+ - Contributions: 87 lines QML, 192 lines config
+ - Commits: 4 commits
+
+17. **Gonen Gazit**
+ - Email: gonengazit@gmail.com
+ - Contributions: 128 lines QML
+ - Commits: 2 commits
+
+18. **sam**
+ - Email: haminoooin@gmail.com
+ - Contributions: 121 lines QML
+ - Commits: 1 commit
+
+19. **Kyle Moore**
+ - Email: kylerm42@users.noreply.github.com
+ - Contributions: 117 lines QML
+ - Commits: 1 commit
+
+20. **xdenotte**
+ - Email: nottechi999@gmail.com, 73490483+xdenotte@users.noreply.github.com
+ - Contributions: 114 lines QML, 4 lines other code
+ - Commits: 6 commits
+
+21. **cashmere**
+ - Email: cashmere@autistici.org
+ - Contributions: 103 lines config/theme files
+ - Commits: 3 commits
+
+---
+
+## License Change Process
+
+To relicense this project from GPL-3.0 to MIT, explicit consent is required from all contributors listed above (excluding automated bots and trivial contributions).
+
+### Contact Template
+
+```
+Subject: Permission to relicense DankMaterialShell from GPL-3.0 to MIT
+
+Hi [Name],
+
+I'm the maintainer of DankMaterialShell. You contributed to this project with the following:
+
+- [Describe their specific contributions]
+- Commits: [Number of commits]
+
+I'm planning to change the license from GPL-3.0 to MIT to make it easier for others to use and integrate this project into their work, including commercial and proprietary software.
+
+Could you please reply confirming you agree to relicense your contributions under MIT? A simple "I agree" or "I consent to relicensing under MIT" is sufficient.
+
+If you have any concerns or questions, I'm happy to discuss them.
+
+Thank you,
+[Your name]
+```
+
+### Tracking Consent
+
+- [ ] bbedward (Primary author - self)
+- [ ] purian23
+- [ ] Bruno Cesar Rocha
+- [ ] Massimo Branchini (max72bra)
+- [ ] Jon Rogers
+- [ ] Aziz Hasanain
+- [ ] Aleksandr Lebedev
+- [ ] Mattias
+- [ ] bokicoder
+- [ ] Oleksandr
+- [ ] lonerorz
+- [ ] Eduardo B. A.
+- [ ] blue linden
+- [ ] Rishi Vora
+- [ ] asaadmohammed74
+- [ ] Parthiv Seetharaman
+- [ ] Gonen Gazit
+- [ ] sam
+- [ ] Kyle Moore
+- [ ] xdenotte
+- [ ] cashmere
+
+---
+
+## Notes
+
+- **github-actions[bot]** and **copilot-swe-agent[bot]** are automated processes and do not hold copyright
+- Contributors with <50 lines of meaningful code changes are not listed as their contributions are likely below the threshold of originality
+- Some contributors appear with multiple email addresses - these have been consolidated
+- Configuration file contributions (Nix, TOML, CSS themes) are included as they involve creative decisions
+
+## Total Contributors Requiring Consent
+
+**21 contributors** (including the primary author)
+
+## Alternative: Rewrite Instead of Relicense
+
+If any contributor cannot be reached or does not consent, their specific contributions can be identified and rewritten to enable relicensing. The primary codebase (>90%) is controlled by the two primary authors (bbedward and purian23).
diff --git a/Common/Anims.qml b/quickshell/Common/Anims.qml
similarity index 100%
rename from Common/Anims.qml
rename to quickshell/Common/Anims.qml
diff --git a/Common/AppUsageHistoryData.qml b/quickshell/Common/AppUsageHistoryData.qml
similarity index 100%
rename from Common/AppUsageHistoryData.qml
rename to quickshell/Common/AppUsageHistoryData.qml
diff --git a/Common/Appearance.qml b/quickshell/Common/Appearance.qml
similarity index 100%
rename from Common/Appearance.qml
rename to quickshell/Common/Appearance.qml
diff --git a/Common/CacheData.qml b/quickshell/Common/CacheData.qml
similarity index 100%
rename from Common/CacheData.qml
rename to quickshell/Common/CacheData.qml
diff --git a/Common/CacheUtils.qml b/quickshell/Common/CacheUtils.qml
similarity index 100%
rename from Common/CacheUtils.qml
rename to quickshell/Common/CacheUtils.qml
diff --git a/Common/DankSocket.qml b/quickshell/Common/DankSocket.qml
similarity index 100%
rename from Common/DankSocket.qml
rename to quickshell/Common/DankSocket.qml
diff --git a/Common/Facts.qml b/quickshell/Common/Facts.qml
similarity index 100%
rename from Common/Facts.qml
rename to quickshell/Common/Facts.qml
diff --git a/Common/I18n.qml b/quickshell/Common/I18n.qml
similarity index 100%
rename from Common/I18n.qml
rename to quickshell/Common/I18n.qml
diff --git a/Common/ModalManager.qml b/quickshell/Common/ModalManager.qml
similarity index 100%
rename from Common/ModalManager.qml
rename to quickshell/Common/ModalManager.qml
diff --git a/Common/Paths.qml b/quickshell/Common/Paths.qml
similarity index 100%
rename from Common/Paths.qml
rename to quickshell/Common/Paths.qml
diff --git a/Common/Proc.qml b/quickshell/Common/Proc.qml
similarity index 100%
rename from Common/Proc.qml
rename to quickshell/Common/Proc.qml
diff --git a/Common/Ref.qml b/quickshell/Common/Ref.qml
similarity index 100%
rename from Common/Ref.qml
rename to quickshell/Common/Ref.qml
diff --git a/Common/SessionData.qml b/quickshell/Common/SessionData.qml
similarity index 100%
rename from Common/SessionData.qml
rename to quickshell/Common/SessionData.qml
diff --git a/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml
similarity index 100%
rename from Common/SettingsData.qml
rename to quickshell/Common/SettingsData.qml
diff --git a/Common/StockThemes.js b/quickshell/Common/StockThemes.js
similarity index 100%
rename from Common/StockThemes.js
rename to quickshell/Common/StockThemes.js
diff --git a/Common/Theme.qml b/quickshell/Common/Theme.qml
similarity index 100%
rename from Common/Theme.qml
rename to quickshell/Common/Theme.qml
diff --git a/Common/fzf.js b/quickshell/Common/fzf.js
similarity index 100%
rename from Common/fzf.js
rename to quickshell/Common/fzf.js
diff --git a/Common/markdown2html.js b/quickshell/Common/markdown2html.js
similarity index 100%
rename from Common/markdown2html.js
rename to quickshell/Common/markdown2html.js
diff --git a/Common/settings/Lists.qml b/quickshell/Common/settings/Lists.qml
similarity index 100%
rename from Common/settings/Lists.qml
rename to quickshell/Common/settings/Lists.qml
diff --git a/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml
similarity index 100%
rename from Common/settings/Processes.qml
rename to quickshell/Common/settings/Processes.qml
diff --git a/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js
similarity index 100%
rename from Common/settings/SettingsSpec.js
rename to quickshell/Common/settings/SettingsSpec.js
diff --git a/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js
similarity index 100%
rename from Common/settings/SettingsStore.js
rename to quickshell/Common/settings/SettingsStore.js
diff --git a/DMSGreeter.qml b/quickshell/DMSGreeter.qml
similarity index 100%
rename from DMSGreeter.qml
rename to quickshell/DMSGreeter.qml
diff --git a/DMSShell.qml b/quickshell/DMSShell.qml
similarity index 100%
rename from DMSShell.qml
rename to quickshell/DMSShell.qml
diff --git a/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml
similarity index 100%
rename from DMSShellIPC.qml
rename to quickshell/DMSShellIPC.qml
diff --git a/Modals/BluetoothPairingModal.qml b/quickshell/Modals/BluetoothPairingModal.qml
similarity index 100%
rename from Modals/BluetoothPairingModal.qml
rename to quickshell/Modals/BluetoothPairingModal.qml
diff --git a/Modals/Clipboard/ClipboardConstants.qml b/quickshell/Modals/Clipboard/ClipboardConstants.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardConstants.qml
rename to quickshell/Modals/Clipboard/ClipboardConstants.qml
diff --git a/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardContent.qml
rename to quickshell/Modals/Clipboard/ClipboardContent.qml
diff --git a/Modals/Clipboard/ClipboardEntry.qml b/quickshell/Modals/Clipboard/ClipboardEntry.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardEntry.qml
rename to quickshell/Modals/Clipboard/ClipboardEntry.qml
diff --git a/Modals/Clipboard/ClipboardHeader.qml b/quickshell/Modals/Clipboard/ClipboardHeader.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardHeader.qml
rename to quickshell/Modals/Clipboard/ClipboardHeader.qml
diff --git a/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardHistoryModal.qml
rename to quickshell/Modals/Clipboard/ClipboardHistoryModal.qml
diff --git a/Modals/Clipboard/ClipboardKeyboardController.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardKeyboardController.qml
rename to quickshell/Modals/Clipboard/ClipboardKeyboardController.qml
diff --git a/Modals/Clipboard/ClipboardKeyboardHints.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardKeyboardHints.qml
rename to quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml
diff --git a/Modals/Clipboard/ClipboardProcesses.qml b/quickshell/Modals/Clipboard/ClipboardProcesses.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardProcesses.qml
rename to quickshell/Modals/Clipboard/ClipboardProcesses.qml
diff --git a/Modals/Clipboard/ClipboardThumbnail.qml b/quickshell/Modals/Clipboard/ClipboardThumbnail.qml
similarity index 100%
rename from Modals/Clipboard/ClipboardThumbnail.qml
rename to quickshell/Modals/Clipboard/ClipboardThumbnail.qml
diff --git a/Modals/Common/ConfirmModal.qml b/quickshell/Modals/Common/ConfirmModal.qml
similarity index 100%
rename from Modals/Common/ConfirmModal.qml
rename to quickshell/Modals/Common/ConfirmModal.qml
diff --git a/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml
similarity index 100%
rename from Modals/Common/DankModal.qml
rename to quickshell/Modals/Common/DankModal.qml
diff --git a/Modals/DankColorPickerModal.qml b/quickshell/Modals/DankColorPickerModal.qml
similarity index 100%
rename from Modals/DankColorPickerModal.qml
rename to quickshell/Modals/DankColorPickerModal.qml
diff --git a/Modals/DisplayConfirmationModal.qml b/quickshell/Modals/DisplayConfirmationModal.qml
similarity index 100%
rename from Modals/DisplayConfirmationModal.qml
rename to quickshell/Modals/DisplayConfirmationModal.qml
diff --git a/Modals/FileBrowser/FileBrowserGridDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserGridDelegate.qml
rename to quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml
diff --git a/Modals/FileBrowser/FileBrowserListDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserListDelegate.qml
rename to quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml
diff --git a/Modals/FileBrowser/FileBrowserModal.qml b/quickshell/Modals/FileBrowser/FileBrowserModal.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserModal.qml
rename to quickshell/Modals/FileBrowser/FileBrowserModal.qml
diff --git a/Modals/FileBrowser/FileBrowserNavigation.qml b/quickshell/Modals/FileBrowser/FileBrowserNavigation.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserNavigation.qml
rename to quickshell/Modals/FileBrowser/FileBrowserNavigation.qml
diff --git a/Modals/FileBrowser/FileBrowserOverwriteDialog.qml b/quickshell/Modals/FileBrowser/FileBrowserOverwriteDialog.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserOverwriteDialog.qml
rename to quickshell/Modals/FileBrowser/FileBrowserOverwriteDialog.qml
diff --git a/Modals/FileBrowser/FileBrowserSaveRow.qml b/quickshell/Modals/FileBrowser/FileBrowserSaveRow.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserSaveRow.qml
rename to quickshell/Modals/FileBrowser/FileBrowserSaveRow.qml
diff --git a/Modals/FileBrowser/FileBrowserSidebar.qml b/quickshell/Modals/FileBrowser/FileBrowserSidebar.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserSidebar.qml
rename to quickshell/Modals/FileBrowser/FileBrowserSidebar.qml
diff --git a/Modals/FileBrowser/FileBrowserSortMenu.qml b/quickshell/Modals/FileBrowser/FileBrowserSortMenu.qml
similarity index 100%
rename from Modals/FileBrowser/FileBrowserSortMenu.qml
rename to quickshell/Modals/FileBrowser/FileBrowserSortMenu.qml
diff --git a/Modals/FileBrowser/FileInfo.qml b/quickshell/Modals/FileBrowser/FileInfo.qml
similarity index 100%
rename from Modals/FileBrowser/FileInfo.qml
rename to quickshell/Modals/FileBrowser/FileInfo.qml
diff --git a/Modals/FileBrowser/KeyboardHints.qml b/quickshell/Modals/FileBrowser/KeyboardHints.qml
similarity index 100%
rename from Modals/FileBrowser/KeyboardHints.qml
rename to quickshell/Modals/FileBrowser/KeyboardHints.qml
diff --git a/Modals/KeybindsModal.qml b/quickshell/Modals/KeybindsModal.qml
similarity index 100%
rename from Modals/KeybindsModal.qml
rename to quickshell/Modals/KeybindsModal.qml
diff --git a/Modals/NetworkInfoModal.qml b/quickshell/Modals/NetworkInfoModal.qml
similarity index 100%
rename from Modals/NetworkInfoModal.qml
rename to quickshell/Modals/NetworkInfoModal.qml
diff --git a/Modals/NetworkWiredInfoModal.qml b/quickshell/Modals/NetworkWiredInfoModal.qml
similarity index 100%
rename from Modals/NetworkWiredInfoModal.qml
rename to quickshell/Modals/NetworkWiredInfoModal.qml
diff --git a/Modals/NotificationModal.qml b/quickshell/Modals/NotificationModal.qml
similarity index 100%
rename from Modals/NotificationModal.qml
rename to quickshell/Modals/NotificationModal.qml
diff --git a/Modals/PolkitAuthModal.qml b/quickshell/Modals/PolkitAuthModal.qml
similarity index 100%
rename from Modals/PolkitAuthModal.qml
rename to quickshell/Modals/PolkitAuthModal.qml
diff --git a/Modals/PowerMenuModal.qml b/quickshell/Modals/PowerMenuModal.qml
similarity index 100%
rename from Modals/PowerMenuModal.qml
rename to quickshell/Modals/PowerMenuModal.qml
diff --git a/Modals/ProcessListModal.qml b/quickshell/Modals/ProcessListModal.qml
similarity index 100%
rename from Modals/ProcessListModal.qml
rename to quickshell/Modals/ProcessListModal.qml
diff --git a/Modals/Settings/PowerSettings.qml b/quickshell/Modals/Settings/PowerSettings.qml
similarity index 100%
rename from Modals/Settings/PowerSettings.qml
rename to quickshell/Modals/Settings/PowerSettings.qml
diff --git a/Modals/Settings/ProfileSection.qml b/quickshell/Modals/Settings/ProfileSection.qml
similarity index 100%
rename from Modals/Settings/ProfileSection.qml
rename to quickshell/Modals/Settings/ProfileSection.qml
diff --git a/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml
similarity index 100%
rename from Modals/Settings/SettingsContent.qml
rename to quickshell/Modals/Settings/SettingsContent.qml
diff --git a/Modals/Settings/SettingsModal.qml b/quickshell/Modals/Settings/SettingsModal.qml
similarity index 100%
rename from Modals/Settings/SettingsModal.qml
rename to quickshell/Modals/Settings/SettingsModal.qml
diff --git a/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml
similarity index 100%
rename from Modals/Settings/SettingsSidebar.qml
rename to quickshell/Modals/Settings/SettingsSidebar.qml
diff --git a/Modals/Spotlight/FileSearchController.qml b/quickshell/Modals/Spotlight/FileSearchController.qml
similarity index 100%
rename from Modals/Spotlight/FileSearchController.qml
rename to quickshell/Modals/Spotlight/FileSearchController.qml
diff --git a/Modals/Spotlight/FileSearchEntry.qml b/quickshell/Modals/Spotlight/FileSearchEntry.qml
similarity index 100%
rename from Modals/Spotlight/FileSearchEntry.qml
rename to quickshell/Modals/Spotlight/FileSearchEntry.qml
diff --git a/Modals/Spotlight/FileSearchResults.qml b/quickshell/Modals/Spotlight/FileSearchResults.qml
similarity index 100%
rename from Modals/Spotlight/FileSearchResults.qml
rename to quickshell/Modals/Spotlight/FileSearchResults.qml
diff --git a/Modals/Spotlight/SpotlightContent.qml b/quickshell/Modals/Spotlight/SpotlightContent.qml
similarity index 100%
rename from Modals/Spotlight/SpotlightContent.qml
rename to quickshell/Modals/Spotlight/SpotlightContent.qml
diff --git a/Modals/Spotlight/SpotlightContextMenu.qml b/quickshell/Modals/Spotlight/SpotlightContextMenu.qml
similarity index 100%
rename from Modals/Spotlight/SpotlightContextMenu.qml
rename to quickshell/Modals/Spotlight/SpotlightContextMenu.qml
diff --git a/Modals/Spotlight/SpotlightModal.qml b/quickshell/Modals/Spotlight/SpotlightModal.qml
similarity index 100%
rename from Modals/Spotlight/SpotlightModal.qml
rename to quickshell/Modals/Spotlight/SpotlightModal.qml
diff --git a/Modals/Spotlight/SpotlightResults.qml b/quickshell/Modals/Spotlight/SpotlightResults.qml
similarity index 100%
rename from Modals/Spotlight/SpotlightResults.qml
rename to quickshell/Modals/Spotlight/SpotlightResults.qml
diff --git a/Modals/WifiPasswordModal.qml b/quickshell/Modals/WifiPasswordModal.qml
similarity index 100%
rename from Modals/WifiPasswordModal.qml
rename to quickshell/Modals/WifiPasswordModal.qml
diff --git a/Modules/AppDrawer/AppDrawerPopout.qml b/quickshell/Modules/AppDrawer/AppDrawerPopout.qml
similarity index 100%
rename from Modules/AppDrawer/AppDrawerPopout.qml
rename to quickshell/Modules/AppDrawer/AppDrawerPopout.qml
diff --git a/Modules/AppDrawer/AppLauncher.qml b/quickshell/Modules/AppDrawer/AppLauncher.qml
similarity index 100%
rename from Modules/AppDrawer/AppLauncher.qml
rename to quickshell/Modules/AppDrawer/AppLauncher.qml
diff --git a/Modules/AppDrawer/CategorySelector.qml b/quickshell/Modules/AppDrawer/CategorySelector.qml
similarity index 100%
rename from Modules/AppDrawer/CategorySelector.qml
rename to quickshell/Modules/AppDrawer/CategorySelector.qml
diff --git a/Modules/BlurredWallpaperBackground.qml b/quickshell/Modules/BlurredWallpaperBackground.qml
similarity index 100%
rename from Modules/BlurredWallpaperBackground.qml
rename to quickshell/Modules/BlurredWallpaperBackground.qml
diff --git a/Modules/ControlCenter/BuiltinPlugins/CupsWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/CupsWidget.qml
similarity index 100%
rename from Modules/ControlCenter/BuiltinPlugins/CupsWidget.qml
rename to quickshell/Modules/ControlCenter/BuiltinPlugins/CupsWidget.qml
diff --git a/Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml
similarity index 100%
rename from Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml
rename to quickshell/Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml
diff --git a/Modules/ControlCenter/Components/ActionTile.qml b/quickshell/Modules/ControlCenter/Components/ActionTile.qml
similarity index 100%
rename from Modules/ControlCenter/Components/ActionTile.qml
rename to quickshell/Modules/ControlCenter/Components/ActionTile.qml
diff --git a/Modules/ControlCenter/Components/DetailHost.qml b/quickshell/Modules/ControlCenter/Components/DetailHost.qml
similarity index 100%
rename from Modules/ControlCenter/Components/DetailHost.qml
rename to quickshell/Modules/ControlCenter/Components/DetailHost.qml
diff --git a/Modules/ControlCenter/Components/DragDropDetailHost.qml b/quickshell/Modules/ControlCenter/Components/DragDropDetailHost.qml
similarity index 100%
rename from Modules/ControlCenter/Components/DragDropDetailHost.qml
rename to quickshell/Modules/ControlCenter/Components/DragDropDetailHost.qml
diff --git a/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml
similarity index 100%
rename from Modules/ControlCenter/Components/DragDropGrid.qml
rename to quickshell/Modules/ControlCenter/Components/DragDropGrid.qml
diff --git a/Modules/ControlCenter/Components/DragDropWidgetWrapper.qml b/quickshell/Modules/ControlCenter/Components/DragDropWidgetWrapper.qml
similarity index 100%
rename from Modules/ControlCenter/Components/DragDropWidgetWrapper.qml
rename to quickshell/Modules/ControlCenter/Components/DragDropWidgetWrapper.qml
diff --git a/Modules/ControlCenter/Components/EditControls.qml b/quickshell/Modules/ControlCenter/Components/EditControls.qml
similarity index 100%
rename from Modules/ControlCenter/Components/EditControls.qml
rename to quickshell/Modules/ControlCenter/Components/EditControls.qml
diff --git a/Modules/ControlCenter/Components/HeaderPane.qml b/quickshell/Modules/ControlCenter/Components/HeaderPane.qml
similarity index 100%
rename from Modules/ControlCenter/Components/HeaderPane.qml
rename to quickshell/Modules/ControlCenter/Components/HeaderPane.qml
diff --git a/Modules/ControlCenter/Components/PowerButton.qml b/quickshell/Modules/ControlCenter/Components/PowerButton.qml
similarity index 100%
rename from Modules/ControlCenter/Components/PowerButton.qml
rename to quickshell/Modules/ControlCenter/Components/PowerButton.qml
diff --git a/Modules/ControlCenter/Components/SizeControls.qml b/quickshell/Modules/ControlCenter/Components/SizeControls.qml
similarity index 100%
rename from Modules/ControlCenter/Components/SizeControls.qml
rename to quickshell/Modules/ControlCenter/Components/SizeControls.qml
diff --git a/Modules/ControlCenter/Components/Typography.qml b/quickshell/Modules/ControlCenter/Components/Typography.qml
similarity index 100%
rename from Modules/ControlCenter/Components/Typography.qml
rename to quickshell/Modules/ControlCenter/Components/Typography.qml
diff --git a/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml
similarity index 100%
rename from Modules/ControlCenter/ControlCenterPopout.qml
rename to quickshell/Modules/ControlCenter/ControlCenterPopout.qml
diff --git a/Modules/ControlCenter/Details/AudioInputDetail.qml b/quickshell/Modules/ControlCenter/Details/AudioInputDetail.qml
similarity index 100%
rename from Modules/ControlCenter/Details/AudioInputDetail.qml
rename to quickshell/Modules/ControlCenter/Details/AudioInputDetail.qml
diff --git a/Modules/ControlCenter/Details/AudioOutputDetail.qml b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml
similarity index 100%
rename from Modules/ControlCenter/Details/AudioOutputDetail.qml
rename to quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml
diff --git a/Modules/ControlCenter/Details/BatteryDetail.qml b/quickshell/Modules/ControlCenter/Details/BatteryDetail.qml
similarity index 100%
rename from Modules/ControlCenter/Details/BatteryDetail.qml
rename to quickshell/Modules/ControlCenter/Details/BatteryDetail.qml
diff --git a/Modules/ControlCenter/Details/BluetoothCodecSelector.qml b/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml
similarity index 100%
rename from Modules/ControlCenter/Details/BluetoothCodecSelector.qml
rename to quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml
diff --git a/Modules/ControlCenter/Details/BluetoothDetail.qml b/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml
similarity index 100%
rename from Modules/ControlCenter/Details/BluetoothDetail.qml
rename to quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml
diff --git a/Modules/ControlCenter/Details/BrightnessDetail.qml b/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml
similarity index 100%
rename from Modules/ControlCenter/Details/BrightnessDetail.qml
rename to quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml
diff --git a/Modules/ControlCenter/Details/DiskUsageDetail.qml b/quickshell/Modules/ControlCenter/Details/DiskUsageDetail.qml
similarity index 100%
rename from Modules/ControlCenter/Details/DiskUsageDetail.qml
rename to quickshell/Modules/ControlCenter/Details/DiskUsageDetail.qml
diff --git a/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml
similarity index 100%
rename from Modules/ControlCenter/Details/NetworkDetail.qml
rename to quickshell/Modules/ControlCenter/Details/NetworkDetail.qml
diff --git a/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml
similarity index 100%
rename from Modules/ControlCenter/Models/WidgetModel.qml
rename to quickshell/Modules/ControlCenter/Models/WidgetModel.qml
diff --git a/Modules/ControlCenter/PowerMenu.qml b/quickshell/Modules/ControlCenter/PowerMenu.qml
similarity index 100%
rename from Modules/ControlCenter/PowerMenu.qml
rename to quickshell/Modules/ControlCenter/PowerMenu.qml
diff --git a/Modules/ControlCenter/Widgets/AudioSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/AudioSliderRow.qml
rename to quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml
diff --git a/Modules/ControlCenter/Widgets/BatteryPill.qml b/quickshell/Modules/ControlCenter/Widgets/BatteryPill.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/BatteryPill.qml
rename to quickshell/Modules/ControlCenter/Widgets/BatteryPill.qml
diff --git a/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/BrightnessSliderRow.qml
rename to quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml
diff --git a/Modules/ControlCenter/Widgets/ColorPickerPill.qml b/quickshell/Modules/ControlCenter/Widgets/ColorPickerPill.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/ColorPickerPill.qml
rename to quickshell/Modules/ControlCenter/Widgets/ColorPickerPill.qml
diff --git a/Modules/ControlCenter/Widgets/CompactSlider.qml b/quickshell/Modules/ControlCenter/Widgets/CompactSlider.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/CompactSlider.qml
rename to quickshell/Modules/ControlCenter/Widgets/CompactSlider.qml
diff --git a/Modules/ControlCenter/Widgets/CompoundPill.qml b/quickshell/Modules/ControlCenter/Widgets/CompoundPill.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/CompoundPill.qml
rename to quickshell/Modules/ControlCenter/Widgets/CompoundPill.qml
diff --git a/Modules/ControlCenter/Widgets/DetailView.qml b/quickshell/Modules/ControlCenter/Widgets/DetailView.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/DetailView.qml
rename to quickshell/Modules/ControlCenter/Widgets/DetailView.qml
diff --git a/Modules/ControlCenter/Widgets/DiskUsagePill.qml b/quickshell/Modules/ControlCenter/Widgets/DiskUsagePill.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/DiskUsagePill.qml
rename to quickshell/Modules/ControlCenter/Widgets/DiskUsagePill.qml
diff --git a/Modules/ControlCenter/Widgets/ErrorPill.qml b/quickshell/Modules/ControlCenter/Widgets/ErrorPill.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/ErrorPill.qml
rename to quickshell/Modules/ControlCenter/Widgets/ErrorPill.qml
diff --git a/Modules/ControlCenter/Widgets/InputAudioSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/InputAudioSliderRow.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/InputAudioSliderRow.qml
rename to quickshell/Modules/ControlCenter/Widgets/InputAudioSliderRow.qml
diff --git a/Modules/ControlCenter/Widgets/SmallBatteryButton.qml b/quickshell/Modules/ControlCenter/Widgets/SmallBatteryButton.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/SmallBatteryButton.qml
rename to quickshell/Modules/ControlCenter/Widgets/SmallBatteryButton.qml
diff --git a/Modules/ControlCenter/Widgets/SmallToggleButton.qml b/quickshell/Modules/ControlCenter/Widgets/SmallToggleButton.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/SmallToggleButton.qml
rename to quickshell/Modules/ControlCenter/Widgets/SmallToggleButton.qml
diff --git a/Modules/ControlCenter/Widgets/ToggleButton.qml b/quickshell/Modules/ControlCenter/Widgets/ToggleButton.qml
similarity index 100%
rename from Modules/ControlCenter/Widgets/ToggleButton.qml
rename to quickshell/Modules/ControlCenter/Widgets/ToggleButton.qml
diff --git a/Modules/ControlCenter/utils/layout.js b/quickshell/Modules/ControlCenter/utils/layout.js
similarity index 100%
rename from Modules/ControlCenter/utils/layout.js
rename to quickshell/Modules/ControlCenter/utils/layout.js
diff --git a/Modules/ControlCenter/utils/state.js b/quickshell/Modules/ControlCenter/utils/state.js
similarity index 100%
rename from Modules/ControlCenter/utils/state.js
rename to quickshell/Modules/ControlCenter/utils/state.js
diff --git a/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js
similarity index 100%
rename from Modules/ControlCenter/utils/widgets.js
rename to quickshell/Modules/ControlCenter/utils/widgets.js
diff --git a/Modules/DankBar/AutoHideManager.qml b/quickshell/Modules/DankBar/AutoHideManager.qml
similarity index 100%
rename from Modules/DankBar/AutoHideManager.qml
rename to quickshell/Modules/DankBar/AutoHideManager.qml
diff --git a/Modules/DankBar/AxisContext.qml b/quickshell/Modules/DankBar/AxisContext.qml
similarity index 100%
rename from Modules/DankBar/AxisContext.qml
rename to quickshell/Modules/DankBar/AxisContext.qml
diff --git a/Modules/DankBar/BarCanvas.qml b/quickshell/Modules/DankBar/BarCanvas.qml
similarity index 100%
rename from Modules/DankBar/BarCanvas.qml
rename to quickshell/Modules/DankBar/BarCanvas.qml
diff --git a/Modules/DankBar/CenterSection.qml b/quickshell/Modules/DankBar/CenterSection.qml
similarity index 100%
rename from Modules/DankBar/CenterSection.qml
rename to quickshell/Modules/DankBar/CenterSection.qml
diff --git a/Modules/DankBar/DankBar.qml b/quickshell/Modules/DankBar/DankBar.qml
similarity index 100%
rename from Modules/DankBar/DankBar.qml
rename to quickshell/Modules/DankBar/DankBar.qml
diff --git a/Modules/DankBar/LeftSection.qml b/quickshell/Modules/DankBar/LeftSection.qml
similarity index 100%
rename from Modules/DankBar/LeftSection.qml
rename to quickshell/Modules/DankBar/LeftSection.qml
diff --git a/Modules/DankBar/Popouts/BatteryPopout.qml b/quickshell/Modules/DankBar/Popouts/BatteryPopout.qml
similarity index 100%
rename from Modules/DankBar/Popouts/BatteryPopout.qml
rename to quickshell/Modules/DankBar/Popouts/BatteryPopout.qml
diff --git a/Modules/DankBar/Popouts/VpnPopout.qml b/quickshell/Modules/DankBar/Popouts/VpnPopout.qml
similarity index 100%
rename from Modules/DankBar/Popouts/VpnPopout.qml
rename to quickshell/Modules/DankBar/Popouts/VpnPopout.qml
diff --git a/Modules/DankBar/RightSection.qml b/quickshell/Modules/DankBar/RightSection.qml
similarity index 100%
rename from Modules/DankBar/RightSection.qml
rename to quickshell/Modules/DankBar/RightSection.qml
diff --git a/Modules/DankBar/WidgetHost.qml b/quickshell/Modules/DankBar/WidgetHost.qml
similarity index 100%
rename from Modules/DankBar/WidgetHost.qml
rename to quickshell/Modules/DankBar/WidgetHost.qml
diff --git a/Modules/DankBar/Widgets/AudioVisualization.qml b/quickshell/Modules/DankBar/Widgets/AudioVisualization.qml
similarity index 100%
rename from Modules/DankBar/Widgets/AudioVisualization.qml
rename to quickshell/Modules/DankBar/Widgets/AudioVisualization.qml
diff --git a/Modules/DankBar/Widgets/Battery.qml b/quickshell/Modules/DankBar/Widgets/Battery.qml
similarity index 100%
rename from Modules/DankBar/Widgets/Battery.qml
rename to quickshell/Modules/DankBar/Widgets/Battery.qml
diff --git a/Modules/DankBar/Widgets/ClipboardButton.qml b/quickshell/Modules/DankBar/Widgets/ClipboardButton.qml
similarity index 100%
rename from Modules/DankBar/Widgets/ClipboardButton.qml
rename to quickshell/Modules/DankBar/Widgets/ClipboardButton.qml
diff --git a/Modules/DankBar/Widgets/Clock.qml b/quickshell/Modules/DankBar/Widgets/Clock.qml
similarity index 100%
rename from Modules/DankBar/Widgets/Clock.qml
rename to quickshell/Modules/DankBar/Widgets/Clock.qml
diff --git a/Modules/DankBar/Widgets/ColorPicker.qml b/quickshell/Modules/DankBar/Widgets/ColorPicker.qml
similarity index 100%
rename from Modules/DankBar/Widgets/ColorPicker.qml
rename to quickshell/Modules/DankBar/Widgets/ColorPicker.qml
diff --git a/Modules/DankBar/Widgets/ControlCenterButton.qml b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml
similarity index 100%
rename from Modules/DankBar/Widgets/ControlCenterButton.qml
rename to quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml
diff --git a/Modules/DankBar/Widgets/CpuMonitor.qml b/quickshell/Modules/DankBar/Widgets/CpuMonitor.qml
similarity index 100%
rename from Modules/DankBar/Widgets/CpuMonitor.qml
rename to quickshell/Modules/DankBar/Widgets/CpuMonitor.qml
diff --git a/Modules/DankBar/Widgets/CpuTemperature.qml b/quickshell/Modules/DankBar/Widgets/CpuTemperature.qml
similarity index 100%
rename from Modules/DankBar/Widgets/CpuTemperature.qml
rename to quickshell/Modules/DankBar/Widgets/CpuTemperature.qml
diff --git a/Modules/DankBar/Widgets/DiskUsage.qml b/quickshell/Modules/DankBar/Widgets/DiskUsage.qml
similarity index 100%
rename from Modules/DankBar/Widgets/DiskUsage.qml
rename to quickshell/Modules/DankBar/Widgets/DiskUsage.qml
diff --git a/Modules/DankBar/Widgets/FocusedApp.qml b/quickshell/Modules/DankBar/Widgets/FocusedApp.qml
similarity index 100%
rename from Modules/DankBar/Widgets/FocusedApp.qml
rename to quickshell/Modules/DankBar/Widgets/FocusedApp.qml
diff --git a/Modules/DankBar/Widgets/GpuTemperature.qml b/quickshell/Modules/DankBar/Widgets/GpuTemperature.qml
similarity index 100%
rename from Modules/DankBar/Widgets/GpuTemperature.qml
rename to quickshell/Modules/DankBar/Widgets/GpuTemperature.qml
diff --git a/Modules/DankBar/Widgets/IdleInhibitor.qml b/quickshell/Modules/DankBar/Widgets/IdleInhibitor.qml
similarity index 100%
rename from Modules/DankBar/Widgets/IdleInhibitor.qml
rename to quickshell/Modules/DankBar/Widgets/IdleInhibitor.qml
diff --git a/Modules/DankBar/Widgets/KeyboardLayoutName.qml b/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml
similarity index 100%
rename from Modules/DankBar/Widgets/KeyboardLayoutName.qml
rename to quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml
diff --git a/Modules/DankBar/Widgets/LauncherButton.qml b/quickshell/Modules/DankBar/Widgets/LauncherButton.qml
similarity index 100%
rename from Modules/DankBar/Widgets/LauncherButton.qml
rename to quickshell/Modules/DankBar/Widgets/LauncherButton.qml
diff --git a/Modules/DankBar/Widgets/Media.qml b/quickshell/Modules/DankBar/Widgets/Media.qml
similarity index 100%
rename from Modules/DankBar/Widgets/Media.qml
rename to quickshell/Modules/DankBar/Widgets/Media.qml
diff --git a/Modules/DankBar/Widgets/NetworkMonitor.qml b/quickshell/Modules/DankBar/Widgets/NetworkMonitor.qml
similarity index 100%
rename from Modules/DankBar/Widgets/NetworkMonitor.qml
rename to quickshell/Modules/DankBar/Widgets/NetworkMonitor.qml
diff --git a/Modules/DankBar/Widgets/NotepadButton.qml b/quickshell/Modules/DankBar/Widgets/NotepadButton.qml
similarity index 100%
rename from Modules/DankBar/Widgets/NotepadButton.qml
rename to quickshell/Modules/DankBar/Widgets/NotepadButton.qml
diff --git a/Modules/DankBar/Widgets/NotificationCenterButton.qml b/quickshell/Modules/DankBar/Widgets/NotificationCenterButton.qml
similarity index 100%
rename from Modules/DankBar/Widgets/NotificationCenterButton.qml
rename to quickshell/Modules/DankBar/Widgets/NotificationCenterButton.qml
diff --git a/Modules/DankBar/Widgets/PrivacyIndicator.qml b/quickshell/Modules/DankBar/Widgets/PrivacyIndicator.qml
similarity index 100%
rename from Modules/DankBar/Widgets/PrivacyIndicator.qml
rename to quickshell/Modules/DankBar/Widgets/PrivacyIndicator.qml
diff --git a/Modules/DankBar/Widgets/RamMonitor.qml b/quickshell/Modules/DankBar/Widgets/RamMonitor.qml
similarity index 100%
rename from Modules/DankBar/Widgets/RamMonitor.qml
rename to quickshell/Modules/DankBar/Widgets/RamMonitor.qml
diff --git a/Modules/DankBar/Widgets/RunningApps.qml b/quickshell/Modules/DankBar/Widgets/RunningApps.qml
similarity index 100%
rename from Modules/DankBar/Widgets/RunningApps.qml
rename to quickshell/Modules/DankBar/Widgets/RunningApps.qml
diff --git a/Modules/DankBar/Widgets/SystemTrayBar.qml b/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml
similarity index 100%
rename from Modules/DankBar/Widgets/SystemTrayBar.qml
rename to quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml
diff --git a/Modules/DankBar/Widgets/SystemUpdate.qml b/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml
similarity index 100%
rename from Modules/DankBar/Widgets/SystemUpdate.qml
rename to quickshell/Modules/DankBar/Widgets/SystemUpdate.qml
diff --git a/Modules/DankBar/Widgets/Vpn.qml b/quickshell/Modules/DankBar/Widgets/Vpn.qml
similarity index 100%
rename from Modules/DankBar/Widgets/Vpn.qml
rename to quickshell/Modules/DankBar/Widgets/Vpn.qml
diff --git a/Modules/DankBar/Widgets/Weather.qml b/quickshell/Modules/DankBar/Widgets/Weather.qml
similarity index 100%
rename from Modules/DankBar/Widgets/Weather.qml
rename to quickshell/Modules/DankBar/Widgets/Weather.qml
diff --git a/Modules/DankBar/Widgets/WorkspaceSwitcher.qml b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml
similarity index 100%
rename from Modules/DankBar/Widgets/WorkspaceSwitcher.qml
rename to quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml
diff --git a/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml
similarity index 100%
rename from Modules/DankDash/DankDashPopout.qml
rename to quickshell/Modules/DankDash/DankDashPopout.qml
diff --git a/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml
similarity index 100%
rename from Modules/DankDash/MediaPlayerTab.qml
rename to quickshell/Modules/DankDash/MediaPlayerTab.qml
diff --git a/Modules/DankDash/Overview/CalendarOverviewCard.qml b/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml
similarity index 100%
rename from Modules/DankDash/Overview/CalendarOverviewCard.qml
rename to quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml
diff --git a/Modules/DankDash/Overview/Card.qml b/quickshell/Modules/DankDash/Overview/Card.qml
similarity index 100%
rename from Modules/DankDash/Overview/Card.qml
rename to quickshell/Modules/DankDash/Overview/Card.qml
diff --git a/Modules/DankDash/Overview/ClockCard.qml b/quickshell/Modules/DankDash/Overview/ClockCard.qml
similarity index 100%
rename from Modules/DankDash/Overview/ClockCard.qml
rename to quickshell/Modules/DankDash/Overview/ClockCard.qml
diff --git a/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml
similarity index 100%
rename from Modules/DankDash/Overview/MediaOverviewCard.qml
rename to quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml
diff --git a/Modules/DankDash/Overview/SystemMonitorCard.qml b/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml
similarity index 100%
rename from Modules/DankDash/Overview/SystemMonitorCard.qml
rename to quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml
diff --git a/Modules/DankDash/Overview/UserInfoCard.qml b/quickshell/Modules/DankDash/Overview/UserInfoCard.qml
similarity index 100%
rename from Modules/DankDash/Overview/UserInfoCard.qml
rename to quickshell/Modules/DankDash/Overview/UserInfoCard.qml
diff --git a/Modules/DankDash/Overview/WeatherOverviewCard.qml b/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml
similarity index 100%
rename from Modules/DankDash/Overview/WeatherOverviewCard.qml
rename to quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml
diff --git a/Modules/DankDash/OverviewTab.qml b/quickshell/Modules/DankDash/OverviewTab.qml
similarity index 100%
rename from Modules/DankDash/OverviewTab.qml
rename to quickshell/Modules/DankDash/OverviewTab.qml
diff --git a/Modules/DankDash/WallpaperTab.qml b/quickshell/Modules/DankDash/WallpaperTab.qml
similarity index 100%
rename from Modules/DankDash/WallpaperTab.qml
rename to quickshell/Modules/DankDash/WallpaperTab.qml
diff --git a/Modules/DankDash/WeatherTab.qml b/quickshell/Modules/DankDash/WeatherTab.qml
similarity index 100%
rename from Modules/DankDash/WeatherTab.qml
rename to quickshell/Modules/DankDash/WeatherTab.qml
diff --git a/Modules/Dock/Dock.qml b/quickshell/Modules/Dock/Dock.qml
similarity index 100%
rename from Modules/Dock/Dock.qml
rename to quickshell/Modules/Dock/Dock.qml
diff --git a/Modules/Dock/DockAppButton.qml b/quickshell/Modules/Dock/DockAppButton.qml
similarity index 100%
rename from Modules/Dock/DockAppButton.qml
rename to quickshell/Modules/Dock/DockAppButton.qml
diff --git a/Modules/Dock/DockApps.qml b/quickshell/Modules/Dock/DockApps.qml
similarity index 100%
rename from Modules/Dock/DockApps.qml
rename to quickshell/Modules/Dock/DockApps.qml
diff --git a/Modules/Dock/DockContextMenu.qml b/quickshell/Modules/Dock/DockContextMenu.qml
similarity index 100%
rename from Modules/Dock/DockContextMenu.qml
rename to quickshell/Modules/Dock/DockContextMenu.qml
diff --git a/Modules/Greetd/GreetdMemory.qml b/quickshell/Modules/Greetd/GreetdMemory.qml
similarity index 100%
rename from Modules/Greetd/GreetdMemory.qml
rename to quickshell/Modules/Greetd/GreetdMemory.qml
diff --git a/Modules/Greetd/GreetdSettings.qml b/quickshell/Modules/Greetd/GreetdSettings.qml
similarity index 100%
rename from Modules/Greetd/GreetdSettings.qml
rename to quickshell/Modules/Greetd/GreetdSettings.qml
diff --git a/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml
similarity index 100%
rename from Modules/Greetd/GreeterContent.qml
rename to quickshell/Modules/Greetd/GreeterContent.qml
diff --git a/Modules/Greetd/GreeterState.qml b/quickshell/Modules/Greetd/GreeterState.qml
similarity index 100%
rename from Modules/Greetd/GreeterState.qml
rename to quickshell/Modules/Greetd/GreeterState.qml
diff --git a/Modules/Greetd/GreeterSurface.qml b/quickshell/Modules/Greetd/GreeterSurface.qml
similarity index 100%
rename from Modules/Greetd/GreeterSurface.qml
rename to quickshell/Modules/Greetd/GreeterSurface.qml
diff --git a/Modules/Greetd/README.md b/quickshell/Modules/Greetd/README.md
similarity index 100%
rename from Modules/Greetd/README.md
rename to quickshell/Modules/Greetd/README.md
diff --git a/Modules/Greetd/assets/dms-greeter b/quickshell/Modules/Greetd/assets/dms-greeter
similarity index 100%
rename from Modules/Greetd/assets/dms-greeter
rename to quickshell/Modules/Greetd/assets/dms-greeter
diff --git a/Modules/Greetd/assets/dms-hypr.conf b/quickshell/Modules/Greetd/assets/dms-hypr.conf
similarity index 100%
rename from Modules/Greetd/assets/dms-hypr.conf
rename to quickshell/Modules/Greetd/assets/dms-hypr.conf
diff --git a/Modules/Greetd/assets/dms-niri.kdl b/quickshell/Modules/Greetd/assets/dms-niri.kdl
similarity index 100%
rename from Modules/Greetd/assets/dms-niri.kdl
rename to quickshell/Modules/Greetd/assets/dms-niri.kdl
diff --git a/Modules/Greetd/assets/greet-hyprland.sh b/quickshell/Modules/Greetd/assets/greet-hyprland.sh
similarity index 100%
rename from Modules/Greetd/assets/greet-hyprland.sh
rename to quickshell/Modules/Greetd/assets/greet-hyprland.sh
diff --git a/Modules/Greetd/assets/greet-niri.sh b/quickshell/Modules/Greetd/assets/greet-niri.sh
similarity index 100%
rename from Modules/Greetd/assets/greet-niri.sh
rename to quickshell/Modules/Greetd/assets/greet-niri.sh
diff --git a/Modules/HyprWorkspaces/HyprlandOverview.qml b/quickshell/Modules/HyprWorkspaces/HyprlandOverview.qml
similarity index 100%
rename from Modules/HyprWorkspaces/HyprlandOverview.qml
rename to quickshell/Modules/HyprWorkspaces/HyprlandOverview.qml
diff --git a/Modules/HyprWorkspaces/OverviewWidget.qml b/quickshell/Modules/HyprWorkspaces/OverviewWidget.qml
similarity index 100%
rename from Modules/HyprWorkspaces/OverviewWidget.qml
rename to quickshell/Modules/HyprWorkspaces/OverviewWidget.qml
diff --git a/Modules/HyprWorkspaces/OverviewWindow.qml b/quickshell/Modules/HyprWorkspaces/OverviewWindow.qml
similarity index 100%
rename from Modules/HyprWorkspaces/OverviewWindow.qml
rename to quickshell/Modules/HyprWorkspaces/OverviewWindow.qml
diff --git a/Modules/Lock/CustomButtonKeyboard.qml b/quickshell/Modules/Lock/CustomButtonKeyboard.qml
similarity index 100%
rename from Modules/Lock/CustomButtonKeyboard.qml
rename to quickshell/Modules/Lock/CustomButtonKeyboard.qml
diff --git a/Modules/Lock/Keyboard.qml b/quickshell/Modules/Lock/Keyboard.qml
similarity index 100%
rename from Modules/Lock/Keyboard.qml
rename to quickshell/Modules/Lock/Keyboard.qml
diff --git a/Modules/Lock/KeyboardController.qml b/quickshell/Modules/Lock/KeyboardController.qml
similarity index 100%
rename from Modules/Lock/KeyboardController.qml
rename to quickshell/Modules/Lock/KeyboardController.qml
diff --git a/Modules/Lock/Lock.qml b/quickshell/Modules/Lock/Lock.qml
similarity index 100%
rename from Modules/Lock/Lock.qml
rename to quickshell/Modules/Lock/Lock.qml
diff --git a/Modules/Lock/LockPowerMenu.qml b/quickshell/Modules/Lock/LockPowerMenu.qml
similarity index 100%
rename from Modules/Lock/LockPowerMenu.qml
rename to quickshell/Modules/Lock/LockPowerMenu.qml
diff --git a/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml
similarity index 100%
rename from Modules/Lock/LockScreenContent.qml
rename to quickshell/Modules/Lock/LockScreenContent.qml
diff --git a/Modules/Lock/LockScreenDemo.qml b/quickshell/Modules/Lock/LockScreenDemo.qml
similarity index 100%
rename from Modules/Lock/LockScreenDemo.qml
rename to quickshell/Modules/Lock/LockScreenDemo.qml
diff --git a/Modules/Lock/LockSurface.qml b/quickshell/Modules/Lock/LockSurface.qml
similarity index 100%
rename from Modules/Lock/LockSurface.qml
rename to quickshell/Modules/Lock/LockSurface.qml
diff --git a/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml
similarity index 100%
rename from Modules/Lock/Pam.qml
rename to quickshell/Modules/Lock/Pam.qml
diff --git a/Modules/Notepad/Notepad.qml b/quickshell/Modules/Notepad/Notepad.qml
similarity index 100%
rename from Modules/Notepad/Notepad.qml
rename to quickshell/Modules/Notepad/Notepad.qml
diff --git a/Modules/Notepad/NotepadSettings.qml b/quickshell/Modules/Notepad/NotepadSettings.qml
similarity index 100%
rename from Modules/Notepad/NotepadSettings.qml
rename to quickshell/Modules/Notepad/NotepadSettings.qml
diff --git a/Modules/Notepad/NotepadTabs.qml b/quickshell/Modules/Notepad/NotepadTabs.qml
similarity index 100%
rename from Modules/Notepad/NotepadTabs.qml
rename to quickshell/Modules/Notepad/NotepadTabs.qml
diff --git a/Modules/Notepad/NotepadTextEditor.qml b/quickshell/Modules/Notepad/NotepadTextEditor.qml
similarity index 100%
rename from Modules/Notepad/NotepadTextEditor.qml
rename to quickshell/Modules/Notepad/NotepadTextEditor.qml
diff --git a/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml
similarity index 100%
rename from Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml
rename to quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml
diff --git a/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml
similarity index 100%
rename from Modules/Notifications/Center/NotificationCard.qml
rename to quickshell/Modules/Notifications/Center/NotificationCard.qml
diff --git a/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml
similarity index 100%
rename from Modules/Notifications/Center/NotificationCenterPopout.qml
rename to quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml
diff --git a/Modules/Notifications/Center/NotificationEmptyState.qml b/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml
similarity index 100%
rename from Modules/Notifications/Center/NotificationEmptyState.qml
rename to quickshell/Modules/Notifications/Center/NotificationEmptyState.qml
diff --git a/Modules/Notifications/Center/NotificationHeader.qml b/quickshell/Modules/Notifications/Center/NotificationHeader.qml
similarity index 100%
rename from Modules/Notifications/Center/NotificationHeader.qml
rename to quickshell/Modules/Notifications/Center/NotificationHeader.qml
diff --git a/Modules/Notifications/Center/NotificationKeyboardController.qml b/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml
similarity index 100%
rename from Modules/Notifications/Center/NotificationKeyboardController.qml
rename to quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml
diff --git a/Modules/Notifications/Center/NotificationKeyboardHints.qml b/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml
similarity index 100%
rename from Modules/Notifications/Center/NotificationKeyboardHints.qml
rename to quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml
diff --git a/Modules/Notifications/Center/NotificationSettings.qml b/quickshell/Modules/Notifications/Center/NotificationSettings.qml
similarity index 100%
rename from Modules/Notifications/Center/NotificationSettings.qml
rename to quickshell/Modules/Notifications/Center/NotificationSettings.qml
diff --git a/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml
similarity index 100%
rename from Modules/Notifications/Popup/NotificationPopup.qml
rename to quickshell/Modules/Notifications/Popup/NotificationPopup.qml
diff --git a/Modules/Notifications/Popup/NotificationPopupManager.qml b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml
similarity index 100%
rename from Modules/Notifications/Popup/NotificationPopupManager.qml
rename to quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml
diff --git a/Modules/OSD/BrightnessOSD.qml b/quickshell/Modules/OSD/BrightnessOSD.qml
similarity index 100%
rename from Modules/OSD/BrightnessOSD.qml
rename to quickshell/Modules/OSD/BrightnessOSD.qml
diff --git a/Modules/OSD/IdleInhibitorOSD.qml b/quickshell/Modules/OSD/IdleInhibitorOSD.qml
similarity index 100%
rename from Modules/OSD/IdleInhibitorOSD.qml
rename to quickshell/Modules/OSD/IdleInhibitorOSD.qml
diff --git a/Modules/OSD/MicMuteOSD.qml b/quickshell/Modules/OSD/MicMuteOSD.qml
similarity index 100%
rename from Modules/OSD/MicMuteOSD.qml
rename to quickshell/Modules/OSD/MicMuteOSD.qml
diff --git a/Modules/OSD/VolumeOSD.qml b/quickshell/Modules/OSD/VolumeOSD.qml
similarity index 100%
rename from Modules/OSD/VolumeOSD.qml
rename to quickshell/Modules/OSD/VolumeOSD.qml
diff --git a/Modules/Plugins/BasePill.qml b/quickshell/Modules/Plugins/BasePill.qml
similarity index 100%
rename from Modules/Plugins/BasePill.qml
rename to quickshell/Modules/Plugins/BasePill.qml
diff --git a/Modules/Plugins/ColorSetting.qml b/quickshell/Modules/Plugins/ColorSetting.qml
similarity index 100%
rename from Modules/Plugins/ColorSetting.qml
rename to quickshell/Modules/Plugins/ColorSetting.qml
diff --git a/Modules/Plugins/ListSetting.qml b/quickshell/Modules/Plugins/ListSetting.qml
similarity index 100%
rename from Modules/Plugins/ListSetting.qml
rename to quickshell/Modules/Plugins/ListSetting.qml
diff --git a/Modules/Plugins/ListSettingWithInput.qml b/quickshell/Modules/Plugins/ListSettingWithInput.qml
similarity index 100%
rename from Modules/Plugins/ListSettingWithInput.qml
rename to quickshell/Modules/Plugins/ListSettingWithInput.qml
diff --git a/Modules/Plugins/PluginComponent.qml b/quickshell/Modules/Plugins/PluginComponent.qml
similarity index 100%
rename from Modules/Plugins/PluginComponent.qml
rename to quickshell/Modules/Plugins/PluginComponent.qml
diff --git a/Modules/Plugins/PluginControlCenterWrapper.qml b/quickshell/Modules/Plugins/PluginControlCenterWrapper.qml
similarity index 100%
rename from Modules/Plugins/PluginControlCenterWrapper.qml
rename to quickshell/Modules/Plugins/PluginControlCenterWrapper.qml
diff --git a/Modules/Plugins/PluginPopout.qml b/quickshell/Modules/Plugins/PluginPopout.qml
similarity index 100%
rename from Modules/Plugins/PluginPopout.qml
rename to quickshell/Modules/Plugins/PluginPopout.qml
diff --git a/Modules/Plugins/PluginSettings.qml b/quickshell/Modules/Plugins/PluginSettings.qml
similarity index 100%
rename from Modules/Plugins/PluginSettings.qml
rename to quickshell/Modules/Plugins/PluginSettings.qml
diff --git a/Modules/Plugins/PopoutComponent.qml b/quickshell/Modules/Plugins/PopoutComponent.qml
similarity index 100%
rename from Modules/Plugins/PopoutComponent.qml
rename to quickshell/Modules/Plugins/PopoutComponent.qml
diff --git a/Modules/Plugins/SelectionSetting.qml b/quickshell/Modules/Plugins/SelectionSetting.qml
similarity index 100%
rename from Modules/Plugins/SelectionSetting.qml
rename to quickshell/Modules/Plugins/SelectionSetting.qml
diff --git a/Modules/Plugins/SliderSetting.qml b/quickshell/Modules/Plugins/SliderSetting.qml
similarity index 100%
rename from Modules/Plugins/SliderSetting.qml
rename to quickshell/Modules/Plugins/SliderSetting.qml
diff --git a/Modules/Plugins/StringSetting.qml b/quickshell/Modules/Plugins/StringSetting.qml
similarity index 100%
rename from Modules/Plugins/StringSetting.qml
rename to quickshell/Modules/Plugins/StringSetting.qml
diff --git a/Modules/Plugins/ToggleSetting.qml b/quickshell/Modules/Plugins/ToggleSetting.qml
similarity index 100%
rename from Modules/Plugins/ToggleSetting.qml
rename to quickshell/Modules/Plugins/ToggleSetting.qml
diff --git a/Modules/ProcessList/PerformanceTab.qml b/quickshell/Modules/ProcessList/PerformanceTab.qml
similarity index 100%
rename from Modules/ProcessList/PerformanceTab.qml
rename to quickshell/Modules/ProcessList/PerformanceTab.qml
diff --git a/Modules/ProcessList/ProcessContextMenu.qml b/quickshell/Modules/ProcessList/ProcessContextMenu.qml
similarity index 100%
rename from Modules/ProcessList/ProcessContextMenu.qml
rename to quickshell/Modules/ProcessList/ProcessContextMenu.qml
diff --git a/Modules/ProcessList/ProcessListItem.qml b/quickshell/Modules/ProcessList/ProcessListItem.qml
similarity index 100%
rename from Modules/ProcessList/ProcessListItem.qml
rename to quickshell/Modules/ProcessList/ProcessListItem.qml
diff --git a/Modules/ProcessList/ProcessListPopout.qml b/quickshell/Modules/ProcessList/ProcessListPopout.qml
similarity index 100%
rename from Modules/ProcessList/ProcessListPopout.qml
rename to quickshell/Modules/ProcessList/ProcessListPopout.qml
diff --git a/Modules/ProcessList/ProcessListView.qml b/quickshell/Modules/ProcessList/ProcessListView.qml
similarity index 100%
rename from Modules/ProcessList/ProcessListView.qml
rename to quickshell/Modules/ProcessList/ProcessListView.qml
diff --git a/Modules/ProcessList/ProcessesTab.qml b/quickshell/Modules/ProcessList/ProcessesTab.qml
similarity index 100%
rename from Modules/ProcessList/ProcessesTab.qml
rename to quickshell/Modules/ProcessList/ProcessesTab.qml
diff --git a/Modules/ProcessList/SystemOverview.qml b/quickshell/Modules/ProcessList/SystemOverview.qml
similarity index 100%
rename from Modules/ProcessList/SystemOverview.qml
rename to quickshell/Modules/ProcessList/SystemOverview.qml
diff --git a/Modules/ProcessList/SystemTab.qml b/quickshell/Modules/ProcessList/SystemTab.qml
similarity index 100%
rename from Modules/ProcessList/SystemTab.qml
rename to quickshell/Modules/ProcessList/SystemTab.qml
diff --git a/Modules/Settings/AboutTab.qml b/quickshell/Modules/Settings/AboutTab.qml
similarity index 100%
rename from Modules/Settings/AboutTab.qml
rename to quickshell/Modules/Settings/AboutTab.qml
diff --git a/Modules/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml
similarity index 100%
rename from Modules/Settings/DankBarTab.qml
rename to quickshell/Modules/Settings/DankBarTab.qml
diff --git a/Modules/Settings/DisplaysTab.qml b/quickshell/Modules/Settings/DisplaysTab.qml
similarity index 100%
rename from Modules/Settings/DisplaysTab.qml
rename to quickshell/Modules/Settings/DisplaysTab.qml
diff --git a/Modules/Settings/DockTab.qml b/quickshell/Modules/Settings/DockTab.qml
similarity index 100%
rename from Modules/Settings/DockTab.qml
rename to quickshell/Modules/Settings/DockTab.qml
diff --git a/Modules/Settings/LauncherTab.qml b/quickshell/Modules/Settings/LauncherTab.qml
similarity index 100%
rename from Modules/Settings/LauncherTab.qml
rename to quickshell/Modules/Settings/LauncherTab.qml
diff --git a/Modules/Settings/PersonalizationTab.qml b/quickshell/Modules/Settings/PersonalizationTab.qml
similarity index 100%
rename from Modules/Settings/PersonalizationTab.qml
rename to quickshell/Modules/Settings/PersonalizationTab.qml
diff --git a/Modules/Settings/PluginBrowser.qml b/quickshell/Modules/Settings/PluginBrowser.qml
similarity index 100%
rename from Modules/Settings/PluginBrowser.qml
rename to quickshell/Modules/Settings/PluginBrowser.qml
diff --git a/Modules/Settings/PluginListItem.qml b/quickshell/Modules/Settings/PluginListItem.qml
similarity index 100%
rename from Modules/Settings/PluginListItem.qml
rename to quickshell/Modules/Settings/PluginListItem.qml
diff --git a/Modules/Settings/PluginsTab.qml b/quickshell/Modules/Settings/PluginsTab.qml
similarity index 100%
rename from Modules/Settings/PluginsTab.qml
rename to quickshell/Modules/Settings/PluginsTab.qml
diff --git a/Modules/Settings/SettingsSection.qml b/quickshell/Modules/Settings/SettingsSection.qml
similarity index 100%
rename from Modules/Settings/SettingsSection.qml
rename to quickshell/Modules/Settings/SettingsSection.qml
diff --git a/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml
similarity index 100%
rename from Modules/Settings/ThemeColorsTab.qml
rename to quickshell/Modules/Settings/ThemeColorsTab.qml
diff --git a/Modules/Settings/TimeWeatherTab.qml b/quickshell/Modules/Settings/TimeWeatherTab.qml
similarity index 100%
rename from Modules/Settings/TimeWeatherTab.qml
rename to quickshell/Modules/Settings/TimeWeatherTab.qml
diff --git a/Modules/Settings/WidgetSelectionPopup.qml b/quickshell/Modules/Settings/WidgetSelectionPopup.qml
similarity index 100%
rename from Modules/Settings/WidgetSelectionPopup.qml
rename to quickshell/Modules/Settings/WidgetSelectionPopup.qml
diff --git a/Modules/Settings/WidgetTweaksTab.qml b/quickshell/Modules/Settings/WidgetTweaksTab.qml
similarity index 100%
rename from Modules/Settings/WidgetTweaksTab.qml
rename to quickshell/Modules/Settings/WidgetTweaksTab.qml
diff --git a/Modules/Settings/WidgetsTabSection.qml b/quickshell/Modules/Settings/WidgetsTabSection.qml
similarity index 100%
rename from Modules/Settings/WidgetsTabSection.qml
rename to quickshell/Modules/Settings/WidgetsTabSection.qml
diff --git a/Modules/SystemUpdatePopout.qml b/quickshell/Modules/SystemUpdatePopout.qml
similarity index 100%
rename from Modules/SystemUpdatePopout.qml
rename to quickshell/Modules/SystemUpdatePopout.qml
diff --git a/Modules/Toast.qml b/quickshell/Modules/Toast.qml
similarity index 100%
rename from Modules/Toast.qml
rename to quickshell/Modules/Toast.qml
diff --git a/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml
similarity index 100%
rename from Modules/WallpaperBackground.qml
rename to quickshell/Modules/WallpaperBackground.qml
diff --git a/PLUGINS/ColorDemoPlugin/ColorDemoSettings.qml b/quickshell/PLUGINS/ColorDemoPlugin/ColorDemoSettings.qml
similarity index 100%
rename from PLUGINS/ColorDemoPlugin/ColorDemoSettings.qml
rename to quickshell/PLUGINS/ColorDemoPlugin/ColorDemoSettings.qml
diff --git a/PLUGINS/ColorDemoPlugin/ColorDemoWidget.qml b/quickshell/PLUGINS/ColorDemoPlugin/ColorDemoWidget.qml
similarity index 100%
rename from PLUGINS/ColorDemoPlugin/ColorDemoWidget.qml
rename to quickshell/PLUGINS/ColorDemoPlugin/ColorDemoWidget.qml
diff --git a/PLUGINS/ColorDemoPlugin/plugin.json b/quickshell/PLUGINS/ColorDemoPlugin/plugin.json
similarity index 100%
rename from PLUGINS/ColorDemoPlugin/plugin.json
rename to quickshell/PLUGINS/ColorDemoPlugin/plugin.json
diff --git a/PLUGINS/ControlCenterDetailExample/DetailExampleWidget.qml b/quickshell/PLUGINS/ControlCenterDetailExample/DetailExampleWidget.qml
similarity index 100%
rename from PLUGINS/ControlCenterDetailExample/DetailExampleWidget.qml
rename to quickshell/PLUGINS/ControlCenterDetailExample/DetailExampleWidget.qml
diff --git a/PLUGINS/ControlCenterDetailExample/plugin.json b/quickshell/PLUGINS/ControlCenterDetailExample/plugin.json
similarity index 100%
rename from PLUGINS/ControlCenterDetailExample/plugin.json
rename to quickshell/PLUGINS/ControlCenterDetailExample/plugin.json
diff --git a/PLUGINS/ControlCenterExample/ControlCenterExampleWidget.qml b/quickshell/PLUGINS/ControlCenterExample/ControlCenterExampleWidget.qml
similarity index 100%
rename from PLUGINS/ControlCenterExample/ControlCenterExampleWidget.qml
rename to quickshell/PLUGINS/ControlCenterExample/ControlCenterExampleWidget.qml
diff --git a/PLUGINS/ControlCenterExample/plugin.json b/quickshell/PLUGINS/ControlCenterExample/plugin.json
similarity index 100%
rename from PLUGINS/ControlCenterExample/plugin.json
rename to quickshell/PLUGINS/ControlCenterExample/plugin.json
diff --git a/PLUGINS/ExampleEmojiPlugin/EmojiSettings.qml b/quickshell/PLUGINS/ExampleEmojiPlugin/EmojiSettings.qml
similarity index 100%
rename from PLUGINS/ExampleEmojiPlugin/EmojiSettings.qml
rename to quickshell/PLUGINS/ExampleEmojiPlugin/EmojiSettings.qml
diff --git a/PLUGINS/ExampleEmojiPlugin/EmojiWidget.qml b/quickshell/PLUGINS/ExampleEmojiPlugin/EmojiWidget.qml
similarity index 100%
rename from PLUGINS/ExampleEmojiPlugin/EmojiWidget.qml
rename to quickshell/PLUGINS/ExampleEmojiPlugin/EmojiWidget.qml
diff --git a/PLUGINS/ExampleEmojiPlugin/README.md b/quickshell/PLUGINS/ExampleEmojiPlugin/README.md
similarity index 100%
rename from PLUGINS/ExampleEmojiPlugin/README.md
rename to quickshell/PLUGINS/ExampleEmojiPlugin/README.md
diff --git a/PLUGINS/ExampleEmojiPlugin/plugin.json b/quickshell/PLUGINS/ExampleEmojiPlugin/plugin.json
similarity index 100%
rename from PLUGINS/ExampleEmojiPlugin/plugin.json
rename to quickshell/PLUGINS/ExampleEmojiPlugin/plugin.json
diff --git a/PLUGINS/ExampleWithVariants/README.md b/quickshell/PLUGINS/ExampleWithVariants/README.md
similarity index 100%
rename from PLUGINS/ExampleWithVariants/README.md
rename to quickshell/PLUGINS/ExampleWithVariants/README.md
diff --git a/PLUGINS/ExampleWithVariants/VariantSettings.qml b/quickshell/PLUGINS/ExampleWithVariants/VariantSettings.qml
similarity index 100%
rename from PLUGINS/ExampleWithVariants/VariantSettings.qml
rename to quickshell/PLUGINS/ExampleWithVariants/VariantSettings.qml
diff --git a/PLUGINS/ExampleWithVariants/VariantWidget.qml b/quickshell/PLUGINS/ExampleWithVariants/VariantWidget.qml
similarity index 100%
rename from PLUGINS/ExampleWithVariants/VariantWidget.qml
rename to quickshell/PLUGINS/ExampleWithVariants/VariantWidget.qml
diff --git a/PLUGINS/ExampleWithVariants/plugin.json b/quickshell/PLUGINS/ExampleWithVariants/plugin.json
similarity index 100%
rename from PLUGINS/ExampleWithVariants/plugin.json
rename to quickshell/PLUGINS/ExampleWithVariants/plugin.json
diff --git a/PLUGINS/LauncherExample/LauncherExampleLauncher.qml b/quickshell/PLUGINS/LauncherExample/LauncherExampleLauncher.qml
similarity index 100%
rename from PLUGINS/LauncherExample/LauncherExampleLauncher.qml
rename to quickshell/PLUGINS/LauncherExample/LauncherExampleLauncher.qml
diff --git a/PLUGINS/LauncherExample/LauncherExampleSettings.qml b/quickshell/PLUGINS/LauncherExample/LauncherExampleSettings.qml
similarity index 100%
rename from PLUGINS/LauncherExample/LauncherExampleSettings.qml
rename to quickshell/PLUGINS/LauncherExample/LauncherExampleSettings.qml
diff --git a/PLUGINS/LauncherExample/README.md b/quickshell/PLUGINS/LauncherExample/README.md
similarity index 100%
rename from PLUGINS/LauncherExample/README.md
rename to quickshell/PLUGINS/LauncherExample/README.md
diff --git a/PLUGINS/LauncherExample/plugin.json b/quickshell/PLUGINS/LauncherExample/plugin.json
similarity index 100%
rename from PLUGINS/LauncherExample/plugin.json
rename to quickshell/PLUGINS/LauncherExample/plugin.json
diff --git a/PLUGINS/POPOUT_SERVICE.md b/quickshell/PLUGINS/POPOUT_SERVICE.md
similarity index 100%
rename from PLUGINS/POPOUT_SERVICE.md
rename to quickshell/PLUGINS/POPOUT_SERVICE.md
diff --git a/PLUGINS/PopoutControlExample/PopoutControlSettings.qml b/quickshell/PLUGINS/PopoutControlExample/PopoutControlSettings.qml
similarity index 100%
rename from PLUGINS/PopoutControlExample/PopoutControlSettings.qml
rename to quickshell/PLUGINS/PopoutControlExample/PopoutControlSettings.qml
diff --git a/PLUGINS/PopoutControlExample/PopoutControlWidget.qml b/quickshell/PLUGINS/PopoutControlExample/PopoutControlWidget.qml
similarity index 100%
rename from PLUGINS/PopoutControlExample/PopoutControlWidget.qml
rename to quickshell/PLUGINS/PopoutControlExample/PopoutControlWidget.qml
diff --git a/PLUGINS/PopoutControlExample/README.md b/quickshell/PLUGINS/PopoutControlExample/README.md
similarity index 100%
rename from PLUGINS/PopoutControlExample/README.md
rename to quickshell/PLUGINS/PopoutControlExample/README.md
diff --git a/PLUGINS/PopoutControlExample/plugin.json b/quickshell/PLUGINS/PopoutControlExample/plugin.json
similarity index 100%
rename from PLUGINS/PopoutControlExample/plugin.json
rename to quickshell/PLUGINS/PopoutControlExample/plugin.json
diff --git a/PLUGINS/README.md b/quickshell/PLUGINS/README.md
similarity index 100%
rename from PLUGINS/README.md
rename to quickshell/PLUGINS/README.md
diff --git a/PLUGINS/THEME_REFERENCE.md b/quickshell/PLUGINS/THEME_REFERENCE.md
similarity index 100%
rename from PLUGINS/THEME_REFERENCE.md
rename to quickshell/PLUGINS/THEME_REFERENCE.md
diff --git a/PLUGINS/WallpaperWatcherDaemon/README.md b/quickshell/PLUGINS/WallpaperWatcherDaemon/README.md
similarity index 100%
rename from PLUGINS/WallpaperWatcherDaemon/README.md
rename to quickshell/PLUGINS/WallpaperWatcherDaemon/README.md
diff --git a/PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherDaemon.qml b/quickshell/PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherDaemon.qml
similarity index 100%
rename from PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherDaemon.qml
rename to quickshell/PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherDaemon.qml
diff --git a/PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherSettings.qml b/quickshell/PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherSettings.qml
similarity index 100%
rename from PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherSettings.qml
rename to quickshell/PLUGINS/WallpaperWatcherDaemon/WallpaperWatcherSettings.qml
diff --git a/PLUGINS/WallpaperWatcherDaemon/plugin.json b/quickshell/PLUGINS/WallpaperWatcherDaemon/plugin.json
similarity index 100%
rename from PLUGINS/WallpaperWatcherDaemon/plugin.json
rename to quickshell/PLUGINS/WallpaperWatcherDaemon/plugin.json
diff --git a/PLUGINS/plugin-schema.json b/quickshell/PLUGINS/plugin-schema.json
similarity index 100%
rename from PLUGINS/plugin-schema.json
rename to quickshell/PLUGINS/plugin-schema.json
diff --git a/quickshell/README.md b/quickshell/README.md
new file mode 100644
index 00000000..39aaa515
--- /dev/null
+++ b/quickshell/README.md
@@ -0,0 +1,219 @@
+# DankMaterialShell (dms)
+
+
+
+
+
+
+ ### A modern Wayland desktop shell
+
+ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
+
+[](https://danklinux.com/docs)
+[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
+[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
+[](https://github.com/AvengeMedia/DankMaterialShell/releases)
+[](https://aur.archlinux.org/packages/dms-shell-bin)
+[)](https://aur.archlinux.org/packages/dms-shell-git)
+[](https://ko-fi.com/avengemediallc)
+
+
+
+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
+
+
+
+https://github.com/user-attachments/assets/1200a739-7770-4601-8b85-695ca527819a
+
+
+
+More Screenshots
+
+
+
+
+
+---
+
+## 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.
diff --git a/Services/AppSearchService.qml b/quickshell/Services/AppSearchService.qml
similarity index 100%
rename from Services/AppSearchService.qml
rename to quickshell/Services/AppSearchService.qml
diff --git a/Services/AudioService.qml b/quickshell/Services/AudioService.qml
similarity index 100%
rename from Services/AudioService.qml
rename to quickshell/Services/AudioService.qml
diff --git a/Services/BatteryService.qml b/quickshell/Services/BatteryService.qml
similarity index 100%
rename from Services/BatteryService.qml
rename to quickshell/Services/BatteryService.qml
diff --git a/Services/BluetoothService.qml b/quickshell/Services/BluetoothService.qml
similarity index 100%
rename from Services/BluetoothService.qml
rename to quickshell/Services/BluetoothService.qml
diff --git a/Services/CalendarService.qml b/quickshell/Services/CalendarService.qml
similarity index 100%
rename from Services/CalendarService.qml
rename to quickshell/Services/CalendarService.qml
diff --git a/Services/CavaService.qml b/quickshell/Services/CavaService.qml
similarity index 100%
rename from Services/CavaService.qml
rename to quickshell/Services/CavaService.qml
diff --git a/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml
similarity index 100%
rename from Services/CompositorService.qml
rename to quickshell/Services/CompositorService.qml
diff --git a/Services/CupsService.qml b/quickshell/Services/CupsService.qml
similarity index 100%
rename from Services/CupsService.qml
rename to quickshell/Services/CupsService.qml
diff --git a/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml
similarity index 100%
rename from Services/DMSNetworkService.qml
rename to quickshell/Services/DMSNetworkService.qml
diff --git a/Services/DMSService.qml b/quickshell/Services/DMSService.qml
similarity index 100%
rename from Services/DMSService.qml
rename to quickshell/Services/DMSService.qml
diff --git a/Services/DSearchService.qml b/quickshell/Services/DSearchService.qml
similarity index 100%
rename from Services/DSearchService.qml
rename to quickshell/Services/DSearchService.qml
diff --git a/Services/DesktopService.qml b/quickshell/Services/DesktopService.qml
similarity index 100%
rename from Services/DesktopService.qml
rename to quickshell/Services/DesktopService.qml
diff --git a/Services/DgopService.qml b/quickshell/Services/DgopService.qml
similarity index 100%
rename from Services/DgopService.qml
rename to quickshell/Services/DgopService.qml
diff --git a/Services/DisplayService.qml b/quickshell/Services/DisplayService.qml
similarity index 100%
rename from Services/DisplayService.qml
rename to quickshell/Services/DisplayService.qml
diff --git a/Services/DwlService.qml b/quickshell/Services/DwlService.qml
similarity index 100%
rename from Services/DwlService.qml
rename to quickshell/Services/DwlService.qml
diff --git a/Services/ExtWorkspaceService.qml b/quickshell/Services/ExtWorkspaceService.qml
similarity index 100%
rename from Services/ExtWorkspaceService.qml
rename to quickshell/Services/ExtWorkspaceService.qml
diff --git a/Services/IdleService.qml b/quickshell/Services/IdleService.qml
similarity index 100%
rename from Services/IdleService.qml
rename to quickshell/Services/IdleService.qml
diff --git a/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml
similarity index 100%
rename from Services/KeybindsService.qml
rename to quickshell/Services/KeybindsService.qml
diff --git a/Services/LegacyNetworkService.qml b/quickshell/Services/LegacyNetworkService.qml
similarity index 100%
rename from Services/LegacyNetworkService.qml
rename to quickshell/Services/LegacyNetworkService.qml
diff --git a/Services/MprisController.qml b/quickshell/Services/MprisController.qml
similarity index 100%
rename from Services/MprisController.qml
rename to quickshell/Services/MprisController.qml
diff --git a/Services/NetworkService.qml b/quickshell/Services/NetworkService.qml
similarity index 100%
rename from Services/NetworkService.qml
rename to quickshell/Services/NetworkService.qml
diff --git a/Services/NiriService.qml b/quickshell/Services/NiriService.qml
similarity index 100%
rename from Services/NiriService.qml
rename to quickshell/Services/NiriService.qml
diff --git a/Services/NotepadStorageService.qml b/quickshell/Services/NotepadStorageService.qml
similarity index 100%
rename from Services/NotepadStorageService.qml
rename to quickshell/Services/NotepadStorageService.qml
diff --git a/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml
similarity index 100%
rename from Services/NotificationService.qml
rename to quickshell/Services/NotificationService.qml
diff --git a/Services/PluginService.qml b/quickshell/Services/PluginService.qml
similarity index 100%
rename from Services/PluginService.qml
rename to quickshell/Services/PluginService.qml
diff --git a/Services/PolkitService.qml b/quickshell/Services/PolkitService.qml
similarity index 100%
rename from Services/PolkitService.qml
rename to quickshell/Services/PolkitService.qml
diff --git a/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml
similarity index 100%
rename from Services/PopoutService.qml
rename to quickshell/Services/PopoutService.qml
diff --git a/Services/PortalService.qml b/quickshell/Services/PortalService.qml
similarity index 100%
rename from Services/PortalService.qml
rename to quickshell/Services/PortalService.qml
diff --git a/Services/PrivacyService.qml b/quickshell/Services/PrivacyService.qml
similarity index 100%
rename from Services/PrivacyService.qml
rename to quickshell/Services/PrivacyService.qml
diff --git a/Services/SessionService.qml b/quickshell/Services/SessionService.qml
similarity index 100%
rename from Services/SessionService.qml
rename to quickshell/Services/SessionService.qml
diff --git a/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml
similarity index 100%
rename from Services/SystemUpdateService.qml
rename to quickshell/Services/SystemUpdateService.qml
diff --git a/Services/ToastService.qml b/quickshell/Services/ToastService.qml
similarity index 100%
rename from Services/ToastService.qml
rename to quickshell/Services/ToastService.qml
diff --git a/Services/UserInfoService.qml b/quickshell/Services/UserInfoService.qml
similarity index 100%
rename from Services/UserInfoService.qml
rename to quickshell/Services/UserInfoService.qml
diff --git a/Services/WallpaperCyclingService.qml b/quickshell/Services/WallpaperCyclingService.qml
similarity index 100%
rename from Services/WallpaperCyclingService.qml
rename to quickshell/Services/WallpaperCyclingService.qml
diff --git a/Services/WeatherService.qml b/quickshell/Services/WeatherService.qml
similarity index 100%
rename from Services/WeatherService.qml
rename to quickshell/Services/WeatherService.qml
diff --git a/Services/WlrOutputService.qml b/quickshell/Services/WlrOutputService.qml
similarity index 100%
rename from Services/WlrOutputService.qml
rename to quickshell/Services/WlrOutputService.qml
diff --git a/Services/niri-binds.kdl b/quickshell/Services/niri-binds.kdl
similarity index 100%
rename from Services/niri-binds.kdl
rename to quickshell/Services/niri-binds.kdl
diff --git a/Services/niri-wpblur.kdl b/quickshell/Services/niri-wpblur.kdl
similarity index 100%
rename from Services/niri-wpblur.kdl
rename to quickshell/Services/niri-wpblur.kdl
diff --git a/Shaders/frag/wp_disc.frag b/quickshell/Shaders/frag/wp_disc.frag
similarity index 100%
rename from Shaders/frag/wp_disc.frag
rename to quickshell/Shaders/frag/wp_disc.frag
diff --git a/Shaders/frag/wp_fade.frag b/quickshell/Shaders/frag/wp_fade.frag
similarity index 100%
rename from Shaders/frag/wp_fade.frag
rename to quickshell/Shaders/frag/wp_fade.frag
diff --git a/Shaders/frag/wp_iris_bloom.frag b/quickshell/Shaders/frag/wp_iris_bloom.frag
similarity index 100%
rename from Shaders/frag/wp_iris_bloom.frag
rename to quickshell/Shaders/frag/wp_iris_bloom.frag
diff --git a/Shaders/frag/wp_pixelate.frag b/quickshell/Shaders/frag/wp_pixelate.frag
similarity index 100%
rename from Shaders/frag/wp_pixelate.frag
rename to quickshell/Shaders/frag/wp_pixelate.frag
diff --git a/Shaders/frag/wp_portal.frag b/quickshell/Shaders/frag/wp_portal.frag
similarity index 100%
rename from Shaders/frag/wp_portal.frag
rename to quickshell/Shaders/frag/wp_portal.frag
diff --git a/Shaders/frag/wp_stripes.frag b/quickshell/Shaders/frag/wp_stripes.frag
similarity index 100%
rename from Shaders/frag/wp_stripes.frag
rename to quickshell/Shaders/frag/wp_stripes.frag
diff --git a/Shaders/frag/wp_wipe.frag b/quickshell/Shaders/frag/wp_wipe.frag
similarity index 100%
rename from Shaders/frag/wp_wipe.frag
rename to quickshell/Shaders/frag/wp_wipe.frag
diff --git a/Shaders/qsb/wp_disc.frag.qsb b/quickshell/Shaders/qsb/wp_disc.frag.qsb
similarity index 100%
rename from Shaders/qsb/wp_disc.frag.qsb
rename to quickshell/Shaders/qsb/wp_disc.frag.qsb
diff --git a/Shaders/qsb/wp_fade.frag.qsb b/quickshell/Shaders/qsb/wp_fade.frag.qsb
similarity index 100%
rename from Shaders/qsb/wp_fade.frag.qsb
rename to quickshell/Shaders/qsb/wp_fade.frag.qsb
diff --git a/Shaders/qsb/wp_iris_bloom.frag.qsb b/quickshell/Shaders/qsb/wp_iris_bloom.frag.qsb
similarity index 100%
rename from Shaders/qsb/wp_iris_bloom.frag.qsb
rename to quickshell/Shaders/qsb/wp_iris_bloom.frag.qsb
diff --git a/Shaders/qsb/wp_pixelate.frag.qsb b/quickshell/Shaders/qsb/wp_pixelate.frag.qsb
similarity index 100%
rename from Shaders/qsb/wp_pixelate.frag.qsb
rename to quickshell/Shaders/qsb/wp_pixelate.frag.qsb
diff --git a/Shaders/qsb/wp_portal.frag.qsb b/quickshell/Shaders/qsb/wp_portal.frag.qsb
similarity index 100%
rename from Shaders/qsb/wp_portal.frag.qsb
rename to quickshell/Shaders/qsb/wp_portal.frag.qsb
diff --git a/Shaders/qsb/wp_stripes.frag.qsb b/quickshell/Shaders/qsb/wp_stripes.frag.qsb
similarity index 100%
rename from Shaders/qsb/wp_stripes.frag.qsb
rename to quickshell/Shaders/qsb/wp_stripes.frag.qsb
diff --git a/Shaders/qsb/wp_wipe.frag.qsb b/quickshell/Shaders/qsb/wp_wipe.frag.qsb
similarity index 100%
rename from Shaders/qsb/wp_wipe.frag.qsb
rename to quickshell/Shaders/qsb/wp_wipe.frag.qsb
diff --git a/VERSION b/quickshell/VERSION
similarity index 100%
rename from VERSION
rename to quickshell/VERSION
diff --git a/Widgets/AppIconRenderer.qml b/quickshell/Widgets/AppIconRenderer.qml
similarity index 100%
rename from Widgets/AppIconRenderer.qml
rename to quickshell/Widgets/AppIconRenderer.qml
diff --git a/Widgets/AppLauncherGridDelegate.qml b/quickshell/Widgets/AppLauncherGridDelegate.qml
similarity index 100%
rename from Widgets/AppLauncherGridDelegate.qml
rename to quickshell/Widgets/AppLauncherGridDelegate.qml
diff --git a/Widgets/AppLauncherListDelegate.qml b/quickshell/Widgets/AppLauncherListDelegate.qml
similarity index 100%
rename from Widgets/AppLauncherListDelegate.qml
rename to quickshell/Widgets/AppLauncherListDelegate.qml
diff --git a/Widgets/CachingImage.qml b/quickshell/Widgets/CachingImage.qml
similarity index 100%
rename from Widgets/CachingImage.qml
rename to quickshell/Widgets/CachingImage.qml
diff --git a/Widgets/DankActionButton.qml b/quickshell/Widgets/DankActionButton.qml
similarity index 100%
rename from Widgets/DankActionButton.qml
rename to quickshell/Widgets/DankActionButton.qml
diff --git a/Widgets/DankAlbumArt.qml b/quickshell/Widgets/DankAlbumArt.qml
similarity index 100%
rename from Widgets/DankAlbumArt.qml
rename to quickshell/Widgets/DankAlbumArt.qml
diff --git a/Widgets/DankBackdrop.qml b/quickshell/Widgets/DankBackdrop.qml
similarity index 100%
rename from Widgets/DankBackdrop.qml
rename to quickshell/Widgets/DankBackdrop.qml
diff --git a/Widgets/DankButton.qml b/quickshell/Widgets/DankButton.qml
similarity index 100%
rename from Widgets/DankButton.qml
rename to quickshell/Widgets/DankButton.qml
diff --git a/Widgets/DankButtonGroup.qml b/quickshell/Widgets/DankButtonGroup.qml
similarity index 100%
rename from Widgets/DankButtonGroup.qml
rename to quickshell/Widgets/DankButtonGroup.qml
diff --git a/Widgets/DankCircularImage.qml b/quickshell/Widgets/DankCircularImage.qml
similarity index 100%
rename from Widgets/DankCircularImage.qml
rename to quickshell/Widgets/DankCircularImage.qml
diff --git a/Widgets/DankDropdown.qml b/quickshell/Widgets/DankDropdown.qml
similarity index 100%
rename from Widgets/DankDropdown.qml
rename to quickshell/Widgets/DankDropdown.qml
diff --git a/Widgets/DankFlickable.qml b/quickshell/Widgets/DankFlickable.qml
similarity index 100%
rename from Widgets/DankFlickable.qml
rename to quickshell/Widgets/DankFlickable.qml
diff --git a/Widgets/DankGridView.qml b/quickshell/Widgets/DankGridView.qml
similarity index 100%
rename from Widgets/DankGridView.qml
rename to quickshell/Widgets/DankGridView.qml
diff --git a/Widgets/DankIcon.qml b/quickshell/Widgets/DankIcon.qml
similarity index 100%
rename from Widgets/DankIcon.qml
rename to quickshell/Widgets/DankIcon.qml
diff --git a/Widgets/DankIconPicker.qml b/quickshell/Widgets/DankIconPicker.qml
similarity index 100%
rename from Widgets/DankIconPicker.qml
rename to quickshell/Widgets/DankIconPicker.qml
diff --git a/Widgets/DankListView.qml b/quickshell/Widgets/DankListView.qml
similarity index 100%
rename from Widgets/DankListView.qml
rename to quickshell/Widgets/DankListView.qml
diff --git a/Widgets/DankLocationSearch.qml b/quickshell/Widgets/DankLocationSearch.qml
similarity index 100%
rename from Widgets/DankLocationSearch.qml
rename to quickshell/Widgets/DankLocationSearch.qml
diff --git a/Widgets/DankNFIcon.qml b/quickshell/Widgets/DankNFIcon.qml
similarity index 100%
rename from Widgets/DankNFIcon.qml
rename to quickshell/Widgets/DankNFIcon.qml
diff --git a/Widgets/DankOSD.qml b/quickshell/Widgets/DankOSD.qml
similarity index 100%
rename from Widgets/DankOSD.qml
rename to quickshell/Widgets/DankOSD.qml
diff --git a/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml
similarity index 100%
rename from Widgets/DankPopout.qml
rename to quickshell/Widgets/DankPopout.qml
diff --git a/Widgets/DankRectangle.qml b/quickshell/Widgets/DankRectangle.qml
similarity index 100%
rename from Widgets/DankRectangle.qml
rename to quickshell/Widgets/DankRectangle.qml
diff --git a/Widgets/DankScrollbar.qml b/quickshell/Widgets/DankScrollbar.qml
similarity index 100%
rename from Widgets/DankScrollbar.qml
rename to quickshell/Widgets/DankScrollbar.qml
diff --git a/Widgets/DankSeekbar.qml b/quickshell/Widgets/DankSeekbar.qml
similarity index 100%
rename from Widgets/DankSeekbar.qml
rename to quickshell/Widgets/DankSeekbar.qml
diff --git a/Widgets/DankSlideout.qml b/quickshell/Widgets/DankSlideout.qml
similarity index 100%
rename from Widgets/DankSlideout.qml
rename to quickshell/Widgets/DankSlideout.qml
diff --git a/Widgets/DankSlider.qml b/quickshell/Widgets/DankSlider.qml
similarity index 100%
rename from Widgets/DankSlider.qml
rename to quickshell/Widgets/DankSlider.qml
diff --git a/Widgets/DankTabBar.qml b/quickshell/Widgets/DankTabBar.qml
similarity index 100%
rename from Widgets/DankTabBar.qml
rename to quickshell/Widgets/DankTabBar.qml
diff --git a/Widgets/DankTextField.qml b/quickshell/Widgets/DankTextField.qml
similarity index 100%
rename from Widgets/DankTextField.qml
rename to quickshell/Widgets/DankTextField.qml
diff --git a/Widgets/DankToggle.qml b/quickshell/Widgets/DankToggle.qml
similarity index 100%
rename from Widgets/DankToggle.qml
rename to quickshell/Widgets/DankToggle.qml
diff --git a/Widgets/DankTooltip.qml b/quickshell/Widgets/DankTooltip.qml
similarity index 100%
rename from Widgets/DankTooltip.qml
rename to quickshell/Widgets/DankTooltip.qml
diff --git a/Widgets/M3WaveProgress.qml b/quickshell/Widgets/M3WaveProgress.qml
similarity index 100%
rename from Widgets/M3WaveProgress.qml
rename to quickshell/Widgets/M3WaveProgress.qml
diff --git a/Widgets/PluginGlobalVar.qml b/quickshell/Widgets/PluginGlobalVar.qml
similarity index 100%
rename from Widgets/PluginGlobalVar.qml
rename to quickshell/Widgets/PluginGlobalVar.qml
diff --git a/Widgets/StateLayer.qml b/quickshell/Widgets/StateLayer.qml
similarity index 100%
rename from Widgets/StateLayer.qml
rename to quickshell/Widgets/StateLayer.qml
diff --git a/Widgets/StyledRect.qml b/quickshell/Widgets/StyledRect.qml
similarity index 100%
rename from Widgets/StyledRect.qml
rename to quickshell/Widgets/StyledRect.qml
diff --git a/Widgets/StyledText.qml b/quickshell/Widgets/StyledText.qml
similarity index 100%
rename from Widgets/StyledText.qml
rename to quickshell/Widgets/StyledText.qml
diff --git a/Widgets/StyledTextMetrics.qml b/quickshell/Widgets/StyledTextMetrics.qml
similarity index 100%
rename from Widgets/StyledTextMetrics.qml
rename to quickshell/Widgets/StyledTextMetrics.qml
diff --git a/Widgets/SystemLogo.qml b/quickshell/Widgets/SystemLogo.qml
similarity index 100%
rename from Widgets/SystemLogo.qml
rename to quickshell/Widgets/SystemLogo.qml
diff --git a/alejandra.toml b/quickshell/alejandra.toml
similarity index 100%
rename from alejandra.toml
rename to quickshell/alejandra.toml
diff --git a/assets/dank.svg b/quickshell/assets/dank.svg
similarity index 100%
rename from assets/dank.svg
rename to quickshell/assets/dank.svg
diff --git a/assets/danklogo.svg b/quickshell/assets/danklogo.svg
similarity index 100%
rename from assets/danklogo.svg
rename to quickshell/assets/danklogo.svg
diff --git a/quickshell/assets/danklogo2.svg b/quickshell/assets/danklogo2.svg
new file mode 100644
index 00000000..1ee6d513
--- /dev/null
+++ b/quickshell/assets/danklogo2.svg
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/danklogonormal.svg b/quickshell/assets/danklogonormal.svg
similarity index 100%
rename from assets/danklogonormal.svg
rename to quickshell/assets/danklogonormal.svg
diff --git a/assets/discord.svg b/quickshell/assets/discord.svg
similarity index 100%
rename from assets/discord.svg
rename to quickshell/assets/discord.svg
diff --git a/assets/fonts/inter/InterVariable.ttf b/quickshell/assets/fonts/inter/InterVariable.ttf
similarity index 100%
rename from assets/fonts/inter/InterVariable.ttf
rename to quickshell/assets/fonts/inter/InterVariable.ttf
diff --git a/assets/fonts/inter/LICENSE.txt b/quickshell/assets/fonts/inter/LICENSE.txt
similarity index 100%
rename from assets/fonts/inter/LICENSE.txt
rename to quickshell/assets/fonts/inter/LICENSE.txt
diff --git a/assets/fonts/inter/README.md b/quickshell/assets/fonts/inter/README.md
similarity index 100%
rename from assets/fonts/inter/README.md
rename to quickshell/assets/fonts/inter/README.md
diff --git a/assets/fonts/material-design-icons/LICENSE b/quickshell/assets/fonts/material-design-icons/LICENSE
similarity index 100%
rename from assets/fonts/material-design-icons/LICENSE
rename to quickshell/assets/fonts/material-design-icons/LICENSE
diff --git a/assets/fonts/material-design-icons/README.md b/quickshell/assets/fonts/material-design-icons/README.md
similarity index 100%
rename from assets/fonts/material-design-icons/README.md
rename to quickshell/assets/fonts/material-design-icons/README.md
diff --git a/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].codepoints b/quickshell/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].codepoints
similarity index 100%
rename from assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].codepoints
rename to quickshell/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].codepoints
diff --git a/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].ttf b/quickshell/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].ttf
similarity index 100%
rename from assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].ttf
rename to quickshell/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].ttf
diff --git a/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2 b/quickshell/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2
similarity index 100%
rename from assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2
rename to quickshell/assets/fonts/material-design-icons/variablefont/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFont-Bold.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Bold.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFont-Bold.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Bold.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFont-Light.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Light.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFont-Light.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Light.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFont-Medium.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Medium.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFont-Medium.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Medium.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFont-Retina.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Retina.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFont-Retina.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-Retina.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFont-SemiBold.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-SemiBold.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFont-SemiBold.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFont-SemiBold.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Bold.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Bold.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Bold.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Bold.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Light.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Light.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Light.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Light.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Medium.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Medium.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Medium.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Medium.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Regular.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Regular.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Regular.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Regular.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Retina.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Retina.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Retina.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-Retina.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-SemiBold.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-SemiBold.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontMono-SemiBold.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontMono-SemiBold.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Bold.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Bold.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Bold.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Bold.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Light.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Light.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Light.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Light.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Medium.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Medium.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Medium.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Medium.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Regular.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Regular.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Regular.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Regular.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Retina.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Retina.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Retina.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-Retina.ttf
diff --git a/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-SemiBold.ttf b/quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-SemiBold.ttf
similarity index 100%
rename from assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-SemiBold.ttf
rename to quickshell/assets/fonts/nerd-fonts/FiraCodeNerdFontPropo-SemiBold.ttf
diff --git a/assets/fonts/nerd-fonts/LICENSE b/quickshell/assets/fonts/nerd-fonts/LICENSE
similarity index 100%
rename from assets/fonts/nerd-fonts/LICENSE
rename to quickshell/assets/fonts/nerd-fonts/LICENSE
diff --git a/assets/fonts/nerd-fonts/README.md b/quickshell/assets/fonts/nerd-fonts/README.md
similarity index 100%
rename from assets/fonts/nerd-fonts/README.md
rename to quickshell/assets/fonts/nerd-fonts/README.md
diff --git a/assets/hyprland.svg b/quickshell/assets/hyprland.svg
similarity index 100%
rename from assets/hyprland.svg
rename to quickshell/assets/hyprland.svg
diff --git a/assets/labwc.png b/quickshell/assets/labwc.png
similarity index 100%
rename from assets/labwc.png
rename to quickshell/assets/labwc.png
diff --git a/assets/mango.png b/quickshell/assets/mango.png
similarity index 100%
rename from assets/mango.png
rename to quickshell/assets/mango.png
diff --git a/assets/matrix-logo-white.svg b/quickshell/assets/matrix-logo-white.svg
similarity index 100%
rename from assets/matrix-logo-white.svg
rename to quickshell/assets/matrix-logo-white.svg
diff --git a/assets/niri.svg b/quickshell/assets/niri.svg
similarity index 100%
rename from assets/niri.svg
rename to quickshell/assets/niri.svg
diff --git a/assets/pam/fprint b/quickshell/assets/pam/fprint
similarity index 100%
rename from assets/pam/fprint
rename to quickshell/assets/pam/fprint
diff --git a/assets/reddit.svg b/quickshell/assets/reddit.svg
similarity index 100%
rename from assets/reddit.svg
rename to quickshell/assets/reddit.svg
diff --git a/assets/sounds/freedesktop/CREDITS b/quickshell/assets/sounds/freedesktop/CREDITS
similarity index 100%
rename from assets/sounds/freedesktop/CREDITS
rename to quickshell/assets/sounds/freedesktop/CREDITS
diff --git a/assets/sounds/freedesktop/audio-volume-change.oga b/quickshell/assets/sounds/freedesktop/audio-volume-change.oga
similarity index 100%
rename from assets/sounds/freedesktop/audio-volume-change.oga
rename to quickshell/assets/sounds/freedesktop/audio-volume-change.oga
diff --git a/assets/sounds/freedesktop/audio-volume-change.wav b/quickshell/assets/sounds/freedesktop/audio-volume-change.wav
similarity index 100%
rename from assets/sounds/freedesktop/audio-volume-change.wav
rename to quickshell/assets/sounds/freedesktop/audio-volume-change.wav
diff --git a/assets/sounds/freedesktop/message-new-instant.oga b/quickshell/assets/sounds/freedesktop/message-new-instant.oga
similarity index 100%
rename from assets/sounds/freedesktop/message-new-instant.oga
rename to quickshell/assets/sounds/freedesktop/message-new-instant.oga
diff --git a/assets/sounds/freedesktop/message-new-instant.wav b/quickshell/assets/sounds/freedesktop/message-new-instant.wav
similarity index 100%
rename from assets/sounds/freedesktop/message-new-instant.wav
rename to quickshell/assets/sounds/freedesktop/message-new-instant.wav
diff --git a/assets/sounds/freedesktop/message.oga b/quickshell/assets/sounds/freedesktop/message.oga
similarity index 100%
rename from assets/sounds/freedesktop/message.oga
rename to quickshell/assets/sounds/freedesktop/message.oga
diff --git a/assets/sounds/freedesktop/message.wav b/quickshell/assets/sounds/freedesktop/message.wav
similarity index 100%
rename from assets/sounds/freedesktop/message.wav
rename to quickshell/assets/sounds/freedesktop/message.wav
diff --git a/assets/sounds/plasma/LICENSE b/quickshell/assets/sounds/plasma/LICENSE
similarity index 100%
rename from assets/sounds/plasma/LICENSE
rename to quickshell/assets/sounds/plasma/LICENSE
diff --git a/assets/sounds/plasma/README.md b/quickshell/assets/sounds/plasma/README.md
similarity index 100%
rename from assets/sounds/plasma/README.md
rename to quickshell/assets/sounds/plasma/README.md
diff --git a/assets/sounds/plasma/power-plug.ogg b/quickshell/assets/sounds/plasma/power-plug.ogg
similarity index 100%
rename from assets/sounds/plasma/power-plug.ogg
rename to quickshell/assets/sounds/plasma/power-plug.ogg
diff --git a/assets/sounds/plasma/power-plug.wav b/quickshell/assets/sounds/plasma/power-plug.wav
similarity index 100%
rename from assets/sounds/plasma/power-plug.wav
rename to quickshell/assets/sounds/plasma/power-plug.wav
diff --git a/assets/sounds/plasma/power-unplug.ogg b/quickshell/assets/sounds/plasma/power-unplug.ogg
similarity index 100%
rename from assets/sounds/plasma/power-unplug.ogg
rename to quickshell/assets/sounds/plasma/power-unplug.ogg
diff --git a/assets/sounds/plasma/power-unplug.wav b/quickshell/assets/sounds/plasma/power-unplug.wav
similarity index 100%
rename from assets/sounds/plasma/power-unplug.wav
rename to quickshell/assets/sounds/plasma/power-unplug.wav
diff --git a/assets/sounds/wavconvert.sh b/quickshell/assets/sounds/wavconvert.sh
similarity index 100%
rename from assets/sounds/wavconvert.sh
rename to quickshell/assets/sounds/wavconvert.sh
diff --git a/assets/sway.svg b/quickshell/assets/sway.svg
similarity index 100%
rename from assets/sway.svg
rename to quickshell/assets/sway.svg
diff --git a/assets/systemd/dms.service b/quickshell/assets/systemd/dms.service
similarity index 100%
rename from assets/systemd/dms.service
rename to quickshell/assets/systemd/dms.service
diff --git a/distro/fedora/dms-greeter.spec b/quickshell/distro/fedora/dms-greeter.spec
similarity index 100%
rename from distro/fedora/dms-greeter.spec
rename to quickshell/distro/fedora/dms-greeter.spec
diff --git a/distro/fedora/dms.spec b/quickshell/distro/fedora/dms.spec
similarity index 100%
rename from distro/fedora/dms.spec
rename to quickshell/distro/fedora/dms.spec
diff --git a/flake.lock b/quickshell/flake.lock
similarity index 100%
rename from flake.lock
rename to quickshell/flake.lock
diff --git a/flake.nix b/quickshell/flake.nix
similarity index 100%
rename from flake.nix
rename to quickshell/flake.nix
diff --git a/matugen/configs/alacritty.toml b/quickshell/matugen/configs/alacritty.toml
similarity index 100%
rename from matugen/configs/alacritty.toml
rename to quickshell/matugen/configs/alacritty.toml
diff --git a/matugen/configs/base.toml b/quickshell/matugen/configs/base.toml
similarity index 100%
rename from matugen/configs/base.toml
rename to quickshell/matugen/configs/base.toml
diff --git a/matugen/configs/codium.toml b/quickshell/matugen/configs/codium.toml
similarity index 100%
rename from matugen/configs/codium.toml
rename to quickshell/matugen/configs/codium.toml
diff --git a/matugen/configs/dgop.toml b/quickshell/matugen/configs/dgop.toml
similarity index 100%
rename from matugen/configs/dgop.toml
rename to quickshell/matugen/configs/dgop.toml
diff --git a/matugen/configs/firefox.toml b/quickshell/matugen/configs/firefox.toml
similarity index 100%
rename from matugen/configs/firefox.toml
rename to quickshell/matugen/configs/firefox.toml
diff --git a/matugen/configs/foot.toml b/quickshell/matugen/configs/foot.toml
similarity index 100%
rename from matugen/configs/foot.toml
rename to quickshell/matugen/configs/foot.toml
diff --git a/matugen/configs/ghostty.toml b/quickshell/matugen/configs/ghostty.toml
similarity index 100%
rename from matugen/configs/ghostty.toml
rename to quickshell/matugen/configs/ghostty.toml
diff --git a/matugen/configs/gtk3-dark.toml b/quickshell/matugen/configs/gtk3-dark.toml
similarity index 100%
rename from matugen/configs/gtk3-dark.toml
rename to quickshell/matugen/configs/gtk3-dark.toml
diff --git a/matugen/configs/gtk3-light.toml b/quickshell/matugen/configs/gtk3-light.toml
similarity index 100%
rename from matugen/configs/gtk3-light.toml
rename to quickshell/matugen/configs/gtk3-light.toml
diff --git a/matugen/configs/kitty.toml b/quickshell/matugen/configs/kitty.toml
similarity index 100%
rename from matugen/configs/kitty.toml
rename to quickshell/matugen/configs/kitty.toml
diff --git a/matugen/configs/niri.toml b/quickshell/matugen/configs/niri.toml
similarity index 100%
rename from matugen/configs/niri.toml
rename to quickshell/matugen/configs/niri.toml
diff --git a/matugen/configs/pywalfox.toml b/quickshell/matugen/configs/pywalfox.toml
similarity index 100%
rename from matugen/configs/pywalfox.toml
rename to quickshell/matugen/configs/pywalfox.toml
diff --git a/matugen/configs/qt5ct.toml b/quickshell/matugen/configs/qt5ct.toml
similarity index 100%
rename from matugen/configs/qt5ct.toml
rename to quickshell/matugen/configs/qt5ct.toml
diff --git a/matugen/configs/qt6ct.toml b/quickshell/matugen/configs/qt6ct.toml
similarity index 100%
rename from matugen/configs/qt6ct.toml
rename to quickshell/matugen/configs/qt6ct.toml
diff --git a/matugen/configs/vesktop.toml b/quickshell/matugen/configs/vesktop.toml
similarity index 100%
rename from matugen/configs/vesktop.toml
rename to quickshell/matugen/configs/vesktop.toml
diff --git a/matugen/configs/vscode.toml b/quickshell/matugen/configs/vscode.toml
similarity index 100%
rename from matugen/configs/vscode.toml
rename to quickshell/matugen/configs/vscode.toml
diff --git a/matugen/templates/alacritty.toml b/quickshell/matugen/templates/alacritty.toml
similarity index 100%
rename from matugen/templates/alacritty.toml
rename to quickshell/matugen/templates/alacritty.toml
diff --git a/matugen/templates/dank.json b/quickshell/matugen/templates/dank.json
similarity index 100%
rename from matugen/templates/dank.json
rename to quickshell/matugen/templates/dank.json
diff --git a/matugen/templates/dark-kcolorscheme.colors b/quickshell/matugen/templates/dark-kcolorscheme.colors
similarity index 100%
rename from matugen/templates/dark-kcolorscheme.colors
rename to quickshell/matugen/templates/dark-kcolorscheme.colors
diff --git a/matugen/templates/dgop.json b/quickshell/matugen/templates/dgop.json
similarity index 100%
rename from matugen/templates/dgop.json
rename to quickshell/matugen/templates/dgop.json
diff --git a/matugen/templates/firefox-userchrome.css b/quickshell/matugen/templates/firefox-userchrome.css
similarity index 100%
rename from matugen/templates/firefox-userchrome.css
rename to quickshell/matugen/templates/firefox-userchrome.css
diff --git a/matugen/templates/foot.ini b/quickshell/matugen/templates/foot.ini
similarity index 100%
rename from matugen/templates/foot.ini
rename to quickshell/matugen/templates/foot.ini
diff --git a/matugen/templates/ghostty.conf b/quickshell/matugen/templates/ghostty.conf
similarity index 100%
rename from matugen/templates/ghostty.conf
rename to quickshell/matugen/templates/ghostty.conf
diff --git a/matugen/templates/gtk-colors.css b/quickshell/matugen/templates/gtk-colors.css
similarity index 100%
rename from matugen/templates/gtk-colors.css
rename to quickshell/matugen/templates/gtk-colors.css
diff --git a/matugen/templates/gtk-light-colors.css b/quickshell/matugen/templates/gtk-light-colors.css
similarity index 100%
rename from matugen/templates/gtk-light-colors.css
rename to quickshell/matugen/templates/gtk-light-colors.css
diff --git a/matugen/templates/kcolorscheme.colors b/quickshell/matugen/templates/kcolorscheme.colors
similarity index 100%
rename from matugen/templates/kcolorscheme.colors
rename to quickshell/matugen/templates/kcolorscheme.colors
diff --git a/matugen/templates/kitty-tabs.conf b/quickshell/matugen/templates/kitty-tabs.conf
similarity index 100%
rename from matugen/templates/kitty-tabs.conf
rename to quickshell/matugen/templates/kitty-tabs.conf
diff --git a/matugen/templates/kitty.conf b/quickshell/matugen/templates/kitty.conf
similarity index 100%
rename from matugen/templates/kitty.conf
rename to quickshell/matugen/templates/kitty.conf
diff --git a/matugen/templates/light-kcolorscheme.colors b/quickshell/matugen/templates/light-kcolorscheme.colors
similarity index 100%
rename from matugen/templates/light-kcolorscheme.colors
rename to quickshell/matugen/templates/light-kcolorscheme.colors
diff --git a/matugen/templates/niri-colors.kdl b/quickshell/matugen/templates/niri-colors.kdl
similarity index 100%
rename from matugen/templates/niri-colors.kdl
rename to quickshell/matugen/templates/niri-colors.kdl
diff --git a/matugen/templates/pywalfox-colors.json b/quickshell/matugen/templates/pywalfox-colors.json
similarity index 100%
rename from matugen/templates/pywalfox-colors.json
rename to quickshell/matugen/templates/pywalfox-colors.json
diff --git a/matugen/templates/qtct-colors.conf b/quickshell/matugen/templates/qtct-colors.conf
similarity index 100%
rename from matugen/templates/qtct-colors.conf
rename to quickshell/matugen/templates/qtct-colors.conf
diff --git a/quickshell/matugen/templates/veskto-wip.css b/quickshell/matugen/templates/veskto-wip.css
new file mode 100644
index 00000000..040c2aa1
--- /dev/null
+++ b/quickshell/matugen/templates/veskto-wip.css
@@ -0,0 +1,242 @@
+/**
+ * @name DMS
+ * @description Material Design 3 theme generated by DankMaterialShell
+ * @author DankMaterialShell
+ * @version 2.0.0
+ * @website https://github.com/bbedward/DankMaterialShell
+*/
+
+* {
+ font-family: 'Adwaita Sans', 'Inter Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
+
+ --app-bg: {{colors.background.default.hex}};
+ --background-primary: {{colors.background.default.hex}};
+ --background-secondary: {{colors.background.default.hex}};
+ --background-secondary-alt: {{colors.background.default.hex}};
+ --background-tertiary: {{colors.background.default.hex}};
+
+ --primary-500: {{colors.background.default.hex}};
+ --primary-600: {{colors.background.default.hex}};
+ --primary-630: {{colors.background.default.hex}};
+ --primary-645: {{colors.background.default.hex}};
+ --primary-660: {{colors.background.default.hex}};
+ --primary-700: {{colors.background.default.hex}};
+
+ --channeltextarea-background: {{colors.background.default.hex}};
+ --custom-channel-members-bg: {{colors.background.default.hex}};
+
+ --sidebar-background: {{colors.background.default.hex}};
+ --sidebar-color: {{colors.background.default.hex}};
+ --server-container: {{colors.background.default.hex}};
+ --main-color: {{colors.background.default.hex}};
+ --main-content-color: {{colors.background.default.hex}};
+
+ --profile-gradient-primary-color: {{colors.background.default.hex}};
+ --profile-gradient-secondary-color: {{colors.background.default.hex}};
+ --profile-body-background-color: {{colors.background.default.hex}};
+ --bg-overlay-1: {{colors.background.default.hex}};
+ --bg-overlay-2: {{colors.background.default.hex}};
+ --bg-overlay-3: {{colors.background.default.hex}};
+ --home-background: {{colors.background.default.hex}};
+
+ --__header-bar-background: {{colors.background.default.hex}} !important;
+ --header-secondary: {{colors.background.default.hex}};
+ --channel-header-bg: {{colors.background.default.hex}};
+ --__channel-header-background: {{colors.background.default.hex}} !important;
+
+ --bg-base-tertiary: {{colors.background.default.hex}};
+
+ --card-primary-bg: {{colors.surface_container.default.hex}};
+ --input-background: {{colors.surface_container.default.hex}};
+ --autocomplete-bg: {{colors.surface_container.default.hex}};
+ --background-nested-floating: {{colors.surface_container.default.hex}};
+ --background-floating: {{colors.surface_container.default.hex}};
+ --scrollbar-auto-track: {{colors.surface_container.default.hex}};
+ --scrollbar-thin-track: {{colors.surface_container.default.hex}};
+
+ --border-subtle: {{colors.background.default.hex}};
+ --background-base-lowest: {{colors.background.default.hex}};
+ --background-surface-high: {{colors.background.default.hex}};
+
+ --button-secondary-background: {{colors.surface_container_high.default.hex}};
+ --background-surface-higher: {{colors.surface_container_high.default.hex}};
+ --background-base-lower: {{colors.surface_container_high.default.hex}};
+
+ --background-message-hover: color-mix(in srgb, {{colors.on_surface.default.hex}} 4%, transparent);
+ --button-secondary-background-hover: {{colors.surface_container_highest.default.hex}};
+ --background-base-low: {{colors.surface_container_highest.default.hex}};
+ --background-surface-highest: {{colors.surface_container_highest.default.hex}};
+ --chat-background-default: {{colors.surface_container_highest.default.hex}};
+
+ --button-secondary-background-active: color-mix(in srgb, {{colors.surface_container_highest.default.hex}}, {{colors.primary.default.hex}} 20%);
+
+ --scrollbar-auto-thumb: {{colors.outline.default.hex}};
+ --scrollbar-thin-thumb: {{colors.outline.default.hex}};
+ --interactive-muted: {{colors.outline.default.hex}};
+ --text-muted: {{colors.outline.default.hex}};
+ --background-modifier-hover: {{colors.surface_container_high.default.hex}};
+ --background-modifier-active: {{colors.surface_container_highest.default.hex}};
+ --background-modifier-accent: color-mix(in srgb, {{colors.primary.default.hex}} 12%, transparent);
+ --background-accent: color-mix(in srgb, {{colors.primary.default.hex}} 12%, transparent);
+ --background-mentioned: color-mix(in srgb, {{colors.primary.default.hex}} 8%, transparent);
+ --background-mentioned-hover: color-mix(in srgb, {{colors.primary.default.hex}} 12%, transparent);
+ --mention-foreground: {{colors.primary.default.hex}};
+ --info-warning-foreground: {{colors.primary.default.hex}};
+
+ --input-border: {{colors.outline_variant.default.hex}};
+ --border-normal: {{colors.outline_variant.default.hex}};
+ --icon-secondary: {{colors.on_surface_variant.default.hex}};
+ --icon-tertiary: {{colors.on_surface_variant.default.hex}};
+ --channel-icon: {{colors.on_surface_variant.default.hex}};
+ --channels-default: {{colors.on_surface_variant.default.hex}};
+ --header-primary: {{colors.on_surface.default.hex}};
+ --__lottieIconColor: {{colors.on_surface_variant.default.hex}};
+ --interactive-normal: {{colors.on_surface_variant.default.hex}};
+
+ --red-400: {{colors.error.default.hex}};
+ --background-modifier-selected: color-mix(in srgb, {{colors.primary.default.hex}} 15%, transparent);
+
+ --notice-background-positive: {{colors.primary_container.default.hex}};
+ --notice-text-positive: {{colors.on_primary_container.default.hex}};
+
+ --status-danger: {{colors.error.default.hex}};
+ --button-outline-danger-border: {{colors.error.default.hex}};
+ --button-outline-danger-text: {{colors.error.default.hex}};
+ --button-danger-background: {{colors.error.default.hex}};
+
+ --yellow-300: {{colors.tertiary.default.hex}};
+
+ --brand-experiment: {{colors.primary.default.hex}};
+ --brand-experiment-360: {{colors.primary.default.hex}};
+ --brand-experiment-500: {{colors.primary.default.hex}};
+ --profile-gradient-button-color: {{colors.primary.default.hex}};
+
+ --green-360: {{colors.primary.default.hex}};
+
+ --text-normal: {{colors.on_surface.default.hex}};
+ --text-link: {{colors.primary.default.hex}};
+ --interactive-active: {{colors.on_surface.default.hex}};
+}
+
+rect[fill="#d83a41"] {
+ fill: {{colors.error.default.hex}} !important;
+}
+
+rect[fill="#cc954c"] {
+ fill: {{colors.tertiary.default.hex}} !important;
+}
+
+rect[fill="#40a258"] {
+ fill: {{colors.primary.default.hex}} !important;
+}
+
+[class*="repliedTextContent"],
+[class*="repliedMessage"] [class*="username"],
+[class*="repliedMessage"] [class*="repliedTextPreview"] {
+ color: {{colors.on_surface_variant.default.hex}} !important;
+ opacity: 0.9 !important;
+}
+
+.guilds__5e434,
+.scroller_ef3116 {
+ background: {{colors.surface.default.hex}} !important;
+}
+
+.sidebarList__5e434,
+.container__2637a,
+.scroller__629e4 {
+ background: {{colors.surface.default.hex}} !important;
+}
+
+.container_c8ffbb,
+.members_c8ffbb,
+.member_c8ffbb,
+.membersWrap_c8ffbb {
+ background: {{colors.surface.default.hex}} !important;
+ background-image: none !important;
+}
+
+.chatContent_f75fb0,
+.messagesWrapper__36d07,
+.chatGradient__36d07 {
+ background: {{colors.surface.default.hex}} !important;
+ background-image: none !important;
+}
+
+.subtitleContainer_f75fb0,
+.title_f75fb0,
+.container__9293f,
+.headerBar__8a7fc,
+.title_c38106 {
+ background: {{colors.surface.default.hex}} !important;
+}
+
+.panels__5e434 {
+ background: {{colors.surface_container.default.hex}} !important;
+}
+
+.channelTextArea_f75fb0,
+.form_f75fb0,
+.wrapper__44df5,
+.channelBottomBarArea_f75fb0,
+.attachWrapper__0923f,
+.themedBackground__74017,
+.stackedBars__74017 {
+ background: {{colors.surface_container.default.hex}} !important;
+ background-image: none !important;
+}
+
+.slateTextArea_ec4baf {
+ background: {{colors.surface_container.default.hex}} !important;
+ color: {{colors.on_surface.default.hex}} !important;
+}
+
+.slateTextArea_ec4baf [data-slate-placeholder],
+.slateTextArea_ec4baf [data-slate-node],
+.slateTextArea_ec4baf span[data-slate-zero-width],
+[data-slate-placeholder],
+[class*="placeholder"],
+[class*="slateTextArea"] [class*="placeholder"] {
+ color: {{colors.on_surface_variant.default.hex}} !important;
+ opacity: 0.7 !important;
+}
+
+.message__5126c::before,
+.mentioned__5126c::before,
+.replying__5126c::before,
+.ephemeral__5126c::before {
+ background: none !important;
+ background-image: none !important;
+}
+
+[class*="mention"][class*="channel"],
+a[href*="/channels/"][class*="mention"] {
+ color: {{colors.primary.default.hex}} !important;
+ background: {{colors.surface_container_high.default.hex}} !important;
+}
+
+[class*="numberBadge"],
+[class*="mentionsBadge"] {
+ background-color: {{colors.primary.default.hex}} !important;
+ color: {{colors.on_primary.default.hex}} !important;
+}
+
+.newMessagesBar__0f481 {
+ background-color: {{colors.primary.default.hex}} !important;
+ color: {{colors.on_primary.default.hex}} !important;
+}
+
+.divider__908e2 {
+ border-color: {{colors.primary.default.hex}} !important;
+}
+
+[class*="channel"]:not([class*="muted"]) [class*="name"],
+[class*="channel"]:not([class*="muted"]) [class*="link"] {
+ color: {{colors.on_surface.default.hex}} !important;
+}
+
+[class*="channel"][class*="muted"] [class*="name"],
+[class*="channel"][class*="muted"] [class*="link"] {
+ color: {{colors.on_surface_variant.default.hex}} !important;
+ opacity: 0.6 !important;
+}
\ No newline at end of file
diff --git a/quickshell/matugen/templates/vesktop-base.css b/quickshell/matugen/templates/vesktop-base.css
new file mode 100644
index 00000000..3890a457
--- /dev/null
+++ b/quickshell/matugen/templates/vesktop-base.css
@@ -0,0 +1,217 @@
+/**
+ * @name DMS Discord Base CSS
+ * @author DMS Team
+ * @version 3.0.0
+ * @description Minimal Discord color theme - stock layout with DMS colors
+ * @source https://github.com/yourusername/dankdots
+*/
+
+body {
+ --font-primary: var(--font), 'gg sans';
+ --font-display: var(--font), 'gg sans';
+ --font-code: var(--code-font), 'gg mono';
+}
+
+@property --colors {
+ syntax: 'off | on';
+ inherits: false;
+ initial-value: on;
+}
+
+@container root style(--colors: on) {
+ .visual-refresh body,
+ .visual-refresh .theme-dark:not(.custom-user-profile-theme),
+ .visual-refresh .theme-light:not(.custom-user-profile-theme) {
+ --activity-card-background: var(--bg-3);
+ --autocomplete-bg: var(--bg-3);
+ --background-accent: var(--bg-4);
+ --background-floating: var(--bg-3);
+ --background-nested-floating: var(--bg-2);
+ --background-mentioned: color-mix(in srgb, var(--accent-2), transparent 90%);
+ --background-mentioned-hover: color-mix(in srgb, var(--accent-2), transparent 85%);
+ --background-message-highlight: color-mix(in srgb, var(--text-3), transparent 95%);
+ --background-message-highlight-hover: color-mix(in srgb, var(--text-3), transparent 92%);
+ --background-message-hover: var(--message-hover);
+ --background-primary: var(--bg-2);
+ --background-secondary: var(--bg-3);
+ --background-secondary-alt: var(--bg-3);
+ --background-tertiary: var(--bg-2);
+ --bg-base-primary: var(--bg-2);
+ --bg-base-secondary: var(--bg-2);
+ --bg-base-tertiary: var(--bg-3);
+ --background-mod-subtle: var(--hover);
+ --background-mod-normal: var(--active);
+ --background-mod-strong: var(--active-2);
+ --background-base-low: var(--bg-2);
+ --background-base-lower: var(--bg-2);
+ --background-base-lowest: var(--bg-2);
+ --background-surface-high: var(--bg-3);
+ --background-surface-higher: var(--bg-4);
+ --background-surface-highest: var(--bg-4);
+ --bg-surface-overlay: var(--bg-2);
+ --bg-surface-raised: var(--bg-3);
+ --chat-background-default: var(--bg-3);
+ --chat-text-muted: var(--text-5);
+ --input-background: var(--bg-3);
+ --modal-background: var(--bg-2);
+ --modal-footer-background: var(--bg-2);
+ --background-modifier-accent: var(--hover);
+ --background-modifier-active: var(--active);
+ --background-modifier-hover: var(--hover);
+ --background-modifier-selected: var(--active);
+ --bg-mod-faint: var(--hover);
+ --bg-mod-subtle: var(--bg-3);
+ --bg-mod-strong: var(--bg-3);
+ --bg-brand: var(--accent-2);
+ --border-faint: transparent;
+ --border-subtle: transparent;
+ --border-normal: transparent;
+ --border-strong: transparent;
+ --input-border: transparent;
+ --button-danger-background: var(--red-3);
+ --button-danger-background-active: var(--red-5);
+ --button-danger-background-hover: var(--red-4);
+ --button-danger-background-disabled: var(--red-5);
+ --button-danger-border: transparent;
+ --button-filled-brand-text: var(--text-0);
+ --button-filled-brand-background: var(--accent-3);
+ --button-filled-brand-background-active: var(--accent-5);
+ --button-filled-brand-background-hover: var(--accent-4);
+ --button-filled-brand-border: transparent;
+ --button-filled-brand-inverted-background: var(--text-1);
+ --button-filled-brand-inverted-background-active: var(--text-3);
+ --button-filled-brand-inverted-background-hover: var(--text-2);
+ --button-filled-brand-inverted-text: var(--bg-1);
+ --button-filled-white-background: var(--text-1);
+ --button-filled-white-background-active: var(--text-3);
+ --button-filled-white-background-hover: var(--text-2);
+ --button-filled-white-text: var(--bg-1);
+ --button-outline-danger-background: var(--bg-3);
+ --button-outline-danger-background-active: var(--text-5);
+ --button-outline-danger-background-hover: var(--bg-2);
+ --button-outline-danger-border: transparent;
+ --button-outline-danger-border-active: transparent;
+ --button-outline-danger-border-hover: transparent;
+ --button-outline-danger-text: var(--red-1);
+ --button-outline-danger-text-active: var(--red-1);
+ --button-outline-danger-text-hover: var(--red-1);
+ --button-outline-primary-background: transparent;
+ --button-outline-primary-background-active: var(--active);
+ --button-outline-primary-background-hover: var(--hover);
+ --button-outline-primary-border: transparent;
+ --button-outline-primary-border-active: transparent;
+ --button-outline-primary-border-hover: transparent;
+ --button-outline-primary-text: var(--text-3);
+ --button-outline-primary-text-active: var(--text-3);
+ --button-outline-primary-text-hover: var(--text-3);
+ --button-positive-background: var(--green-2);
+ --button-positive-background-active: var(--green-4);
+ --button-positive-background-hover: var(--green-3);
+ --button-positive-background-disabled: var(--green-4);
+ --button-positive-border: transparent;
+ --button-secondary-background: var(--bg-4);
+ --button-secondary-background-active: var(--text-5);
+ --button-secondary-background-hover: var(--bg-3);
+ --button-secondary-background-disabled: var(--bg-3);
+ --button-secondary-text: var(--text-3);
+ --button-transparent-background: var(--hover);
+ --button-transparent-background-active: var(--active-2);
+ --button-transparent-background-hover: var(--active);
+ --button-transparent-text: var(--text-3);
+ --redesign-button-secondary-text: var(--text-3);
+ --card-primary-bg: var(--bg-3);
+ --card-secondary-bg: var(--bg-4);
+ --channel-icon: var(--text-4);
+ --channels-default: var(--text-4);
+ --embed-title: var(--text-2);
+ --header-primary: var(--text-2);
+ --header-secondary: var(--text-4);
+ --header-muted: var(--text-4);
+ --icon-muted: var(--text-5);
+ --icon-primary: var(--text-3);
+ --icon-secondary: var(--text-4);
+ --icon-tertiary: var(--text-4);
+ --text-brand: var(--accent-1);
+ --text-danger: var(--red-1);
+ --text-link: var(--accent-1);
+ --text-low-contrast: var(--text-4);
+ --text-muted: var(--text-5);
+ --text-muted-on-default: var(--text-4);
+ --text-normal: var(--text-3);
+ --text-positive: var(--green-1);
+ --text-primary: var(--text-3);
+ --text-secondary: var(--text-4);
+ --text-warning: var(--yellow-1);
+ --text-default: var(--text-3);
+ --text-tertiary: var(--text-4);
+ --user-profile-overlay-background: var(--bg-2);
+ --user-profile-overlay-background-hover: var(--bg-4);
+ --status-danger: var(--red-2);
+ --status-danger-background: var(--red-3);
+ --status-danger-text: var(--white);
+ --status-dnd: var(--dnd);
+ --status-idle: var(--idle);
+ --status-offline: var(--offline);
+ --status-online: var(--online);
+ --status-positive: var(--green-2);
+ --status-positive-background: var(--green-2);
+ --status-positive-text: var(--white);
+ --status-speaking: var(--green-2);
+ --status-warning: var(--yellow-2);
+ --interactive-normal: var(--text-4);
+ --interactive-hover: var(--text-3);
+ --interactive-active: var(--text-3);
+ --interactive-muted: var(--text-5);
+ --mention-foreground: var(--accent-1);
+ --mention-background: color-mix(in srgb, var(--accent-2), transparent 90%);
+ --channel-text-area-placeholder: var(--text-5);
+ --message-reacted-text: var(--text-2);
+ --message-reacted-background: color-mix(in srgb, var(--accent-2), transparent 90%);
+ --custom-channel-members-bg: var(--bg-2);
+ --redesign-input-control-selected: var(--accent-2);
+ --scrollbar-auto-thumb: var(--bg-4);
+ --scrollbar-auto-track: transparent;
+ --scrollbar-thin-thumb: var(--bg-4);
+ --scrollbar-thin-track: transparent;
+ --white: var(--text-0);
+ --white-500: var(--text-0);
+ --redesign-button-overlay-alpha-text: var(--text-2);
+ --brand-360: var(--accent-2);
+ --brand-500: var(--accent-2);
+ --blurple-50: var(--accent-2);
+ --red-400: var(--red-2);
+ --red-500: var(--red-3);
+ --green-360: var(--green-2);
+ --primary-400: var(--text-4);
+ --deprecated-text-input-bg: var(--bg-3);
+ --deprecated-text-input-border: transparent;
+ --background-code: var(--bg-3);
+ }
+
+ .visual-refresh {
+ .bg__960e4 { background: var(--bg-1); }
+ .base_c48ade { background: var(--bg-1); }
+ .colorPickerSwatch__459fb[style*="rgb(88, 101, 242)"],
+ .newBadge_faa96b,
+ .mentioned__5126c:before { background-color: var(--accent-2) !important; }
+ .botTagRegular__82f07 { background-color: var(--accent-2); }
+ .container__87bf1.checked__87bf1 { background-color: var(--accent-2) !important; }
+ .container__87bf1.checked__87bf1 .slider__87bf1 > svg > path { fill: var(--accent-2) !important; }
+ .newMessagesBar__0f481 { background-color: var(--accent-3); }
+ .barFill_a562c8 { background-color: var(--accent-2) !important; }
+ ::selection,
+ .highlight { background: var(--accent-3); color: var(--text-0); }
+ rect[fill='#82838b'] { fill: var(--offline); }
+ .status_a423bd[style='background-color: rgb(130, 131, 139);'] { background-color: var(--offline) !important; }
+ rect[fill='#43a25a'],
+ path[fill='#43a25a'],
+ path[fill='var(--status-positive)'] { fill: var(--online); }
+ .status_a423bd[style='background-color: rgb(67, 162, 90);'] { background-color: var(--online) !important; }
+ rect[fill='#ca9654'] { fill: var(--idle); }
+ .status_a423bd[style='background-color: rgb(202, 150, 84);'] { background-color: var(--idle) !important; }
+ rect[fill='#d83a42'] { fill: var(--dnd); }
+ .status_a423bd[style='background-color: rgb(216, 58, 66);'] { background-color: var(--dnd) !important; }
+ rect[fill='#9147ff'] { fill: var(--streaming); }
+ div[style*='background-color: rgb(67, 162, 90)'] { background-color: var(--online) !important; }
+ }
+}
diff --git a/matugen/templates/vesktop.css b/quickshell/matugen/templates/vesktop.css
similarity index 100%
rename from matugen/templates/vesktop.css
rename to quickshell/matugen/templates/vesktop.css
diff --git a/matugen/templates/vscode-color-theme.json b/quickshell/matugen/templates/vscode-color-theme.json
similarity index 100%
rename from matugen/templates/vscode-color-theme.json
rename to quickshell/matugen/templates/vscode-color-theme.json
diff --git a/matugen/templates/vscode-package.json b/quickshell/matugen/templates/vscode-package.json
similarity index 100%
rename from matugen/templates/vscode-package.json
rename to quickshell/matugen/templates/vscode-package.json
diff --git a/matugen/templates/vscode-vsixmanifest.xml b/quickshell/matugen/templates/vscode-vsixmanifest.xml
similarity index 100%
rename from matugen/templates/vscode-vsixmanifest.xml
rename to quickshell/matugen/templates/vscode-vsixmanifest.xml
diff --git a/nix/default.nix b/quickshell/nix/default.nix
similarity index 100%
rename from nix/default.nix
rename to quickshell/nix/default.nix
diff --git a/nix/greeter.nix b/quickshell/nix/greeter.nix
similarity index 100%
rename from nix/greeter.nix
rename to quickshell/nix/greeter.nix
diff --git a/nix/niri.nix b/quickshell/nix/niri.nix
similarity index 100%
rename from nix/niri.nix
rename to quickshell/nix/niri.nix
diff --git a/qmlformat-all.sh b/quickshell/qmlformat-all.sh
similarity index 100%
rename from qmlformat-all.sh
rename to quickshell/qmlformat-all.sh
diff --git a/scripts/gtk.sh b/quickshell/scripts/gtk.sh
similarity index 100%
rename from scripts/gtk.sh
rename to quickshell/scripts/gtk.sh
diff --git a/scripts/i18nsync.py b/quickshell/scripts/i18nsync.py
similarity index 100%
rename from scripts/i18nsync.py
rename to quickshell/scripts/i18nsync.py
diff --git a/scripts/matugen-worker.sh b/quickshell/scripts/matugen-worker.sh
similarity index 100%
rename from scripts/matugen-worker.sh
rename to quickshell/scripts/matugen-worker.sh
diff --git a/scripts/qt.sh b/quickshell/scripts/qt.sh
similarity index 100%
rename from scripts/qt.sh
rename to quickshell/scripts/qt.sh
diff --git a/shell.qml b/quickshell/shell.qml
similarity index 100%
rename from shell.qml
rename to quickshell/shell.qml
diff --git a/systemd/tmpfiles-dms-greeter.conf b/quickshell/systemd/tmpfiles-dms-greeter.conf
similarity index 100%
rename from systemd/tmpfiles-dms-greeter.conf
rename to quickshell/systemd/tmpfiles-dms-greeter.conf
diff --git a/translations/README.md b/quickshell/translations/README.md
similarity index 100%
rename from translations/README.md
rename to quickshell/translations/README.md
diff --git a/translations/WORKFLOW.md b/quickshell/translations/WORKFLOW.md
similarity index 100%
rename from translations/WORKFLOW.md
rename to quickshell/translations/WORKFLOW.md
diff --git a/translations/en.json b/quickshell/translations/en.json
similarity index 99%
rename from translations/en.json
rename to quickshell/translations/en.json
index 50882d8c..81dee513 100644
--- a/translations/en.json
+++ b/quickshell/translations/en.json
@@ -428,7 +428,7 @@
{
"term": "Back",
"context": "Back",
- "reference": "Modules/DankBar/Widgets/SystemTrayBar.qml:863",
+ "reference": "Modules/DankBar/Widgets/SystemTrayBar.qml:864",
"comment": ""
},
{
diff --git a/translations/extract_translations.py b/quickshell/translations/extract_translations.py
similarity index 100%
rename from translations/extract_translations.py
rename to quickshell/translations/extract_translations.py
diff --git a/translations/poexports/it.json b/quickshell/translations/poexports/it.json
similarity index 100%
rename from translations/poexports/it.json
rename to quickshell/translations/poexports/it.json
diff --git a/translations/poexports/ja.json b/quickshell/translations/poexports/ja.json
similarity index 100%
rename from translations/poexports/ja.json
rename to quickshell/translations/poexports/ja.json
diff --git a/translations/poexports/pl.json b/quickshell/translations/poexports/pl.json
similarity index 100%
rename from translations/poexports/pl.json
rename to quickshell/translations/poexports/pl.json
diff --git a/translations/poexports/pt.json b/quickshell/translations/poexports/pt.json
similarity index 100%
rename from translations/poexports/pt.json
rename to quickshell/translations/poexports/pt.json
diff --git a/translations/poexports/tr.json b/quickshell/translations/poexports/tr.json
similarity index 100%
rename from translations/poexports/tr.json
rename to quickshell/translations/poexports/tr.json
diff --git a/translations/poexports/zh_CN.json b/quickshell/translations/poexports/zh_CN.json
similarity index 100%
rename from translations/poexports/zh_CN.json
rename to quickshell/translations/poexports/zh_CN.json
diff --git a/translations/poexports/zh_TW.json b/quickshell/translations/poexports/zh_TW.json
similarity index 100%
rename from translations/poexports/zh_TW.json
rename to quickshell/translations/poexports/zh_TW.json
diff --git a/translations/replace_qstr.py b/quickshell/translations/replace_qstr.py
similarity index 100%
rename from translations/replace_qstr.py
rename to quickshell/translations/replace_qstr.py
diff --git a/translations/template.json b/quickshell/translations/template.json
similarity index 100%
rename from translations/template.json
rename to quickshell/translations/template.json