1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-27 15:02:50 -05:00

Compare commits

..

2 Commits

Author SHA1 Message Date
LuckShiba
da006b883e doctor: use console.warn for quickshell feature logs 2025-12-28 04:56:15 -03:00
LuckShiba
e71fb09cbd feat: doctor command 2025-12-28 04:50:39 -03:00
528 changed files with 19278 additions and 72801 deletions

65
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,65 @@
---
name: Bug Report
about: Crashes or unexpected behaviors
title: ""
labels: "bug"
assignees: ""
---
<!-- If your issue is related to ICONS
- Purple and black checkerboards are QT's way of signalling an icon doesn't exist
- FIX: Configure a QT6 or Icon Pack in DMS Settings that has the icon you want
- Follow the [THEMING](https://danklinux.com/docs/dankmaterialshell/icon-theming) section to ensure your QT environment variable is configured correctly for themes.
- Once done, configure an icon theme - either however you normally do with gtk3 or qt6ct, or through the built-in settings modal. -->
## Compositor
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
- [ ] Other (specify)
## Distribution
<!-- Arch, Fedora, Debian, etc. -->
## dms version
<!-- Output of dms version command -->
## Description
<!-- Brief description of the issue -->
## Expected Behavior
<!-- Describe what you expected to happen -->
## Steps to Reproduce
<!-- Please provide detailed steps to reproduce the issue -->
1.
2.
3.
## Error Messages/Logs
<!-- Please include any error messages, stack traces, or relevant logs -->
<!-- you can get a log file with the following steps:
dms kill
mkdir ~/dms_logs
nohup dms run > ~/dms_logs/dms-$(date +%s).txt 2>&1 &
Then trigger your issue, and share the contents of ~/dms_logs/dms-<timestamp>.txt
-->
```
Paste error messages or logs here
```
## Screenshots/Recordings
<!-- If applicable, add screenshots or screen recordings -->

View File

@@ -1,96 +0,0 @@
name: Bug Report
description: Crashes or unexpected behaviors
labels:
- bug
body:
- type: markdown
attributes:
value: |
## DankMaterialShell Bug Report
Limit your report to one issue per submission unless closely related
- type: checkboxes
id: compositor
attributes:
label: Compositor
options:
- label: Niri
- label: Hyprland
- label: MangoWC (dwl)
- label: Sway
validations:
required: true
- type: checkboxes
id: distribution
attributes:
label: Distribution
options:
- label: Arch Linux
- label: CachyOS
- label: Fedora
- label: NixOS
- label: Debian
- label: Ubuntu
- label: Gentoo
- label: OpenSUSE
- label: Other (specify below)
validations:
required: true
- type: input
id: distribution_other
attributes:
label: If Other, please specify
placeholder: e.g., PikaOS, Void Linux, etc.
validations:
required: false
- type: textarea
id: dms_doctor
attributes:
label: dms doctor -v
description: Output of `dms doctor -v` command
placeholder: Paste the output of `dms doctor -v` here
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Brief description of the issue
placeholder: What happened?
validations:
required: true
- type: textarea
id: expected_behavior
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe the expected behavior
validations:
required: false
- type: textarea
id: steps_to_reproduce
attributes:
label: Steps to Reproduce & Installation Method
description: Please provide detailed steps to reproduce the issue
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Messages/Logs
description: Please include any error messages, stack traces, or relevant logs
placeholder: |
Paste error messages or logs here
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots/Recordings
description: If applicable, add screenshots or screen recordings
placeholder: Attach images or videos here
validations:
required: false

View File

@@ -0,0 +1,33 @@
---
name: Request a Feature
about: New widgets, new widget behavior, etc.
title: ""
labels: "enhancement"
assignees: ""
---
## Feature Description
<!-- Brief description of the feature requested -->
## Use Case
<!-- Explain the purpose of this feature/why it'd be useful to you -->
## Compositor
Is this feature specific to one compositor?
- [ ] All compositors
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
## Proposed Solution
<!-- If you have any ideas for how to implement this, please share! -->
## Alternatives/Existing Solutions
<!-- Include any similar/pre-existing products that solve this problem -->

View File

@@ -1,55 +0,0 @@
name: Feature Request
description: Suggest a new feature or improvement for DMS. Keep features focused on a single topic with clear benefits, examples, etc. Avoid vague or broad requests, they will be closed.
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
## DankMaterialShell Feature Request
- type: textarea
id: feature_description
attributes:
label: Feature Description
description: Brief description of the feature requested
placeholder: What feature would you like to see?
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use Case
description: Explain the purpose of this feature/why it'd be useful to you
placeholder: Why is this feature important?
validations:
required: false
- type: checkboxes
id: compositor
attributes:
label: Compositor(s)
description: Is this feature specific to one or more compositors?
options:
- label: All compositors
- label: Niri
- label: Hyprland
- label: MangoWC (dwl)
- label: Sway
- label: Other (specify below)
validations:
required: false
- type: textarea
id: proposed_solution
attributes:
label: Proposed Solution
description: If you have any ideas for how to implement this, please share!
placeholder: Suggest a solution or approach
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives/Existing Solutions
description: Include any similar/pre-existing products that solve this problem
placeholder: List alternatives or existing solutions
validations:
required: false

View File

@@ -0,0 +1,40 @@
---
name: Request Assistance or Support
about: Help with installation, usage, or general questions.
title: ""
labels: "support"
assignees: ""
---
## Compositor
- [ ] niri
- [ ] Hyprland
- [ ] dwl (MangoWC)
- [ ] sway
- [ ] other
## Distribution
<!-- Arch, Fedora, Debian, etc. -->
## dms version
<!-- Output of dms version command -->
## Description
<!-- Brief description of the support needed -->
## Solutions Tried
<!-- Describe what you've tried so far -->
<!-- Outlining what you've tried so far helps us make improvements to the user experience and documentation to avoid recurrent issues -->
## Configuration Details
<!-- Include any configuration if relevant -->
## Screenshots/Recordings
<!-- If applicable, add screenshots or screen recordings -->

View File

@@ -1,69 +0,0 @@
name: Support Request
description: Help with installation, usage, or general questions about DankMaterialShell
labels:
- support
body:
- type: markdown
attributes:
value: |
## DankMaterialShell Support Request
- type: checkboxes
id: compositor
attributes:
label: Compositor
options:
- label: Niri
- label: Hyprland
- label: MangoWC (dwl)
- label: Sway
- label: Other (specify below)
validations:
required: false
- type: input
id: distribution
attributes:
label: Distribution
description: Which Linux distribution are you using? (e.g., Arch, Fedora, Debian, etc.)
placeholder: Your Linux distribution
validations:
required: false
- type: textarea
id: dms_doctor
attributes:
label: dms doctor -v
description: Output of `dms doctor -v` command
placeholder: Paste the output of `dms doctor -v` here
validations:
required: false
- type: textarea
id: description
attributes:
label: Description
description: Brief description of the support needed
placeholder: What do you need help with?
validations:
required: true
- type: textarea
id: solutions_tried
attributes:
label: Solutions Tried
description: Describe what you've tried so far (commands, documentation, etc.)
placeholder: List steps or resources you've already tried
validations:
required: false
- type: textarea
id: configuration
attributes:
label: Configuration Details
description: Include any relevant configuration if relevant
placeholder: Add configuration or environment info
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots/Recordings
description: If applicable, add screenshots or screen recordings
placeholder: Attach images or videos here
validations:
required: false

View File

@@ -28,15 +28,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
- name: Add flathub
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Add a flatpak that mutagen could support
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:

View File

@@ -11,19 +11,5 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
- name: Add flathub
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Add a flatpak that mutagen could support
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: core/go.mod
- name: run pre-commit hooks - name: run pre-commit hooks
uses: j178/prek-action@v1 uses: j178/prek-action@v1

View File

@@ -4,14 +4,13 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
package: package:
description: "Package to update" description: "Package to update (dms, dms-git, or all)"
required: true required: false
type: choice default: "all"
options: tag_version:
- dms description: "Specific tag version for dms stable (e.g., v1.0.2). Leave empty to auto-detect latest release."
- dms-git required: false
- all default: ""
default: "dms"
rebuild_release: rebuild_release:
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)" description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
required: false required: false
@@ -57,9 +56,8 @@ jobs:
} }
# Helper function to check dms stable tag # Helper function to check dms stable tag
# Sets LATEST_TAG variable in parent scope if update needed
check_dms_stable() { check_dms_stable() {
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") local LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "v\?\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "") local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "") local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
@@ -75,8 +73,8 @@ jobs:
# Main logic # Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}" REBUILD="${{ github.event.inputs.rebuild_release }}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag selected or pushed - always update stable package # Tag push - always update stable package
echo "packages=dms" >> $GITHUB_OUTPUT echo "packages=dms" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}" VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
@@ -106,12 +104,7 @@ jobs:
# Check each package and build list of those needing updates # Check each package and build list of those needing updates
PACKAGES_TO_UPDATE=() PACKAGES_TO_UPDATE=()
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git") check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
if check_dms_stable; then check_dms_stable && PACKAGES_TO_UPDATE+=("dms")
PACKAGES_TO_UPDATE+=("dms")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
fi
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
@@ -136,9 +129,6 @@ jobs:
if check_dms_stable; then if check_dms_stable; then
echo "packages=$PKG" >> $GITHUB_OUTPUT echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT echo "has_updates=true" >> $GITHUB_OUTPUT
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
else else
echo "packages=" >> $GITHUB_OUTPUT echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT echo "has_updates=false" >> $GITHUB_OUTPUT
@@ -171,19 +161,12 @@ jobs:
- name: Determine packages to update - name: Determine packages to update
id: packages id: packages
run: | run: |
# Check if GITHUB_REF points to a tag (works for both push events and workflow_dispatch with tag selected) if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then # Tag push event - use the pushed tag
# Tag selected or pushed - use the tag from GITHUB_REF
echo "packages=dms" >> $GITHUB_OUTPUT echo "packages=dms" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}" VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using tag from GITHUB_REF: $VERSION" echo "Triggered by tag: $VERSION"
# Check if check-updates already determined a version (from auto-detection)
elif [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
# Use version from check-updates job
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}"
elif [[ "${{ github.event_name }}" == "schedule" ]]; then elif [[ "${{ github.event_name }}" == "schedule" ]]; then
# Scheduled run - dms-git only # Scheduled run - dms-git only
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
@@ -193,28 +176,22 @@ jobs:
# Determine version for dms stable # Determine version for dms stable
if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then
# Use github.ref if tag selected, otherwise auto-detect latest # For explicit dms selection, require tag_version
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
VERSION="${GITHUB_REF#refs/tags/}" VERSION="${{ github.event.inputs.tag_version }}"
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using tag from GITHUB_REF: $VERSION" echo "Using specified tag: $VERSION"
else else
# Auto-detect latest release for dms echo "ERROR: tag_version is required when package=dms"
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") echo "Please specify a tag version (e.g., v1.0.2) or use package=all for auto-detection"
if [[ -n "$LATEST_TAG" ]]; then exit 1
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Auto-detected latest release: $LATEST_TAG"
else
echo "ERROR: Could not auto-detect latest release"
exit 1
fi
fi fi
elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then
# Use github.ref if tag selected, otherwise auto-detect latest # For "all", auto-detect if tag_version not specified
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
VERSION="${GITHUB_REF#refs/tags/}" VERSION="${{ github.event.inputs.tag_version }}"
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using tag from GITHUB_REF: $VERSION" echo "Using specified tag: $VERSION"
else else
# Auto-detect latest release for "all" # Auto-detect latest release for "all"
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
@@ -229,7 +206,7 @@ jobs:
fi fi
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified # Use filtered packages from check-updates when package="all" and no rebuild/tag specified
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ -z "${{ github.event.inputs.tag_version }}" ]]; then
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})" echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})"
else else
@@ -238,9 +215,6 @@ jobs:
fi fi
else else
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
fi
fi fi
- name: Update dms-git spec version - name: Update dms-git spec version

View File

@@ -5,27 +5,15 @@ on:
tags: tags:
- "v*" - "v*"
permissions:
contents: write
jobs: jobs:
update-stable: update-stable:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- name: Create GitHub App token - uses: actions/checkout@v4
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Push to stable branch - name: Push to stable branch
env: run: git push origin HEAD:refs/heads/stable --force
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force

2
.gitignore vendored
View File

@@ -108,5 +108,3 @@ bin/
# direnv # direnv
.envrc .envrc
.direnv/ .direnv/
quickshell/dms-plugins
__pycache__

View File

@@ -10,11 +10,3 @@ repos:
hooks: hooks:
- id: shellcheck - id: shellcheck
args: [-e, SC2164, -e, SC2001, -e, SC2012, -e, SC2317] args: [-e, SC2164, -e, SC2001, -e, SC2012, -e, SC2317]
- repo: local
hooks:
- id: go-mod-tidy
name: go mod tidy
entry: bash -c 'cd core && go mod tidy'
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false

View File

@@ -1,14 +1,5 @@
This file is more of a quick reference so I know what to account for before next releases. This file is more of a quick reference so I know what to account for before next releases.
# 1.4.0
- Overhauled system monitor, graphs, styling
- dbus API for plugins, KDEConnect
- new dank16 algorithm
- launcher actions, customize env, args, name, icon
- launcher v2 - omega stuff, GIF search, supa powerful
- dock on bar
# 1.2.0 # 1.2.0
- Added clipboard and clipboard history integration - Added clipboard and clipboard history integration
@@ -24,9 +15,3 @@ This file is more of a quick reference so I know what to account for before next
- new IPC targets - new IPC targets
- Initial RTL support/i18n - Initial RTL support/i18n
- Theme registry - Theme registry
- Notification persistence & history
- **BREAKING** vscode theme needs re-installed
- dms doctor cmd
- niri/hypr/mango gaps/window/border overrides
- settings search
- notification display ops on lock screen

View File

@@ -43,6 +43,7 @@ install-shell:
@mkdir -p $(SHELL_INSTALL_DIR) @mkdir -p $(SHELL_INSTALL_DIR)
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/ @cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github @rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@$(MAKE) --no-print-directory -C $(CORE_DIR) print-version > $(SHELL_INSTALL_DIR)/VERSION
@echo "Shell files installed" @echo "Shell files installed"
install-completions: install-completions:

View File

@@ -68,9 +68,3 @@ packages:
outpkg: mocks_wlclient outpkg: mocks_wlclient
interfaces: interfaces:
WaylandDisplay: WaylandDisplay:
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: "internal/mocks/utils"
outpkg: mocks_utils
interfaces:
AppChecker:

View File

@@ -179,7 +179,7 @@ func runBrightnessList(cmd *cobra.Command, args []string) {
fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness") fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness")
sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15 sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15
for range sepLen { for i := 0; i < sepLen; i++ {
fmt.Print("─") fmt.Print("─")
} }
fmt.Println() fmt.Println()

View File

@@ -1,300 +0,0 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"sync"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
ghtml "github.com/yuin/goldmark/renderer/html"
)
var (
chromaLanguage string
chromaStyle string
chromaInline bool
chromaMarkdown bool
chromaLineNumbers bool
// Caching layer for performance
lexerCache = make(map[string]chroma.Lexer)
styleCache = make(map[string]*chroma.Style)
formatterCache = make(map[string]*html.Formatter)
cacheMutex sync.RWMutex
maxFileSize = int64(5 * 1024 * 1024) // 5MB default
)
var chromaCmd = &cobra.Command{
Use: "chroma [file]",
Short: "Syntax highlight source code",
Long: `Generate syntax-highlighted HTML from source code.
Reads from file or stdin, outputs HTML with syntax highlighting.
Language is auto-detected from filename or can be specified with --language.
Examples:
dms chroma main.go
dms chroma --language python script.py
echo "def foo(): pass" | dms chroma -l python
cat code.rs | dms chroma -l rust --style dracula
dms chroma --markdown README.md
dms chroma --markdown --style github-dark notes.md
dms chroma list-languages
dms chroma list-styles`,
Args: cobra.MaximumNArgs(1),
Run: runChroma,
}
var chromaListLanguagesCmd = &cobra.Command{
Use: "list-languages",
Short: "List all supported languages",
Run: func(cmd *cobra.Command, args []string) {
for _, name := range lexers.Names(true) {
fmt.Println(name)
}
},
}
var chromaListStylesCmd = &cobra.Command{
Use: "list-styles",
Short: "List all available color styles",
Run: func(cmd *cobra.Command, args []string) {
for _, name := range styles.Names() {
fmt.Println(name)
}
},
}
func init() {
chromaCmd.Flags().StringVarP(&chromaLanguage, "language", "l", "", "Language for highlighting (auto-detect if not specified)")
chromaCmd.Flags().StringVarP(&chromaStyle, "style", "s", "monokai", "Color style (monokai, dracula, github, etc.)")
chromaCmd.Flags().BoolVar(&chromaInline, "inline", false, "Output inline styles instead of CSS classes")
chromaCmd.Flags().BoolVar(&chromaLineNumbers, "line-numbers", false, "Show line numbers in output")
chromaCmd.Flags().BoolVarP(&chromaMarkdown, "markdown", "m", false, "Render markdown with syntax-highlighted code blocks")
chromaCmd.Flags().Int64Var(&maxFileSize, "max-size", 5*1024*1024, "Maximum file size to process without warning (bytes)")
chromaCmd.AddCommand(chromaListLanguagesCmd)
chromaCmd.AddCommand(chromaListStylesCmd)
}
func getCachedLexer(key string, fallbackFunc func() chroma.Lexer) chroma.Lexer {
cacheMutex.RLock()
if lexer, ok := lexerCache[key]; ok {
cacheMutex.RUnlock()
return lexer
}
cacheMutex.RUnlock()
lexer := fallbackFunc()
if lexer != nil {
cacheMutex.Lock()
lexerCache[key] = lexer
cacheMutex.Unlock()
}
return lexer
}
func getCachedStyle(name string) *chroma.Style {
cacheMutex.RLock()
if style, ok := styleCache[name]; ok {
cacheMutex.RUnlock()
return style
}
cacheMutex.RUnlock()
style := styles.Get(name)
if style == nil {
fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name)
style = styles.Fallback
}
cacheMutex.Lock()
styleCache[name] = style
cacheMutex.Unlock()
return style
}
func getCachedFormatter(inline bool, lineNumbers bool) *html.Formatter {
key := fmt.Sprintf("inline=%t,lineNumbers=%t", inline, lineNumbers)
cacheMutex.RLock()
if formatter, ok := formatterCache[key]; ok {
cacheMutex.RUnlock()
return formatter
}
cacheMutex.RUnlock()
var opts []html.Option
if inline {
opts = append(opts, html.WithClasses(false))
} else {
opts = append(opts, html.WithClasses(true))
}
opts = append(opts, html.TabWidth(4))
if lineNumbers {
opts = append(opts, html.WithLineNumbers(true))
opts = append(opts, html.LineNumbersInTable(false))
opts = append(opts, html.WithLinkableLineNumbers(false, ""))
}
formatter := html.New(opts...)
cacheMutex.Lock()
formatterCache[key] = formatter
cacheMutex.Unlock()
return formatter
}
func runChroma(cmd *cobra.Command, args []string) {
var source string
var filename string
// Read from file or stdin
if len(args) > 0 {
filename = args[0]
// Check file size before reading
fileInfo, err := os.Stat(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file info: %v\n", err)
os.Exit(1)
}
if fileInfo.Size() > maxFileSize {
fmt.Fprintf(os.Stderr, "Warning: File size (%d bytes) exceeds recommended limit (%d bytes)\n",
fileInfo.Size(), maxFileSize)
fmt.Fprintf(os.Stderr, "Processing may be slow. Consider using smaller files.\n")
}
content, err := os.ReadFile(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
os.Exit(1)
}
source = string(content)
} else {
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) != 0 {
_ = cmd.Help()
os.Exit(0)
}
content, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
os.Exit(1)
}
source = string(content)
}
// Handle empty input
if strings.TrimSpace(source) == "" {
return
}
// Handle Markdown rendering
if chromaMarkdown {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlighting.NewHighlighting(
highlighting.WithStyle(chromaStyle),
highlighting.WithFormatOptions(
html.WithClasses(!chromaInline),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
ghtml.WithHardWraps(),
ghtml.WithXHTML(),
),
)
var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil {
fmt.Fprintf(os.Stderr, "Markdown rendering error: %v\n", err)
os.Exit(1)
}
fmt.Print(buf.String())
return
}
// Detect or use specified lexer
var lexer chroma.Lexer
if chromaLanguage != "" {
lexer = getCachedLexer(chromaLanguage, func() chroma.Lexer {
l := lexers.Get(chromaLanguage)
if l == nil {
fmt.Fprintf(os.Stderr, "Unknown language: %s\n", chromaLanguage)
os.Exit(1)
}
return l
})
} else if filename != "" {
lexer = getCachedLexer("file:"+filename, func() chroma.Lexer {
return lexers.Match(filename)
})
}
// Try content analysis if no lexer found (limit to first 1KB for performance)
if lexer == nil {
analyzeContent := source
if len(source) > 1024 {
analyzeContent = source[:1024]
}
lexer = lexers.Analyse(analyzeContent)
}
// Fallback to plaintext
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer)
// Get cached style
style := getCachedStyle(chromaStyle)
// Get cached formatter
formatter := getCachedFormatter(chromaInline, chromaLineNumbers)
// Tokenize
iterator, err := lexer.Tokenise(nil, source)
if err != nil {
fmt.Fprintf(os.Stderr, "Tokenization error: %v\n", err)
os.Exit(1)
}
// Format and output
if chromaLineNumbers {
var buf bytes.Buffer
if err := formatter.Format(&buf, style, iterator); err != nil {
fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err)
os.Exit(1)
}
// Add spacing between line numbers
output := buf.String()
output = strings.ReplaceAll(output, "</span><span>", "</span>\u00A0\u00A0<span>")
fmt.Print(output)
} else {
if err := formatter.Format(os.Stdout, style, iterator); err != nil {
fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err)
os.Exit(1)
}
}
}

View File

@@ -1,33 +1,17 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io" "io"
"net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
bolt "go.etcd.io/bbolt"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
@@ -52,7 +36,6 @@ var (
clipCopyForeground bool clipCopyForeground bool
clipCopyPasteOnce bool clipCopyPasteOnce bool
clipCopyType string clipCopyType string
clipCopyDownload bool
clipJSONOutput bool clipJSONOutput bool
) )
@@ -159,42 +142,19 @@ var (
clipConfigNoClearStartup bool clipConfigNoClearStartup bool
clipConfigDisabled bool clipConfigDisabled bool
clipConfigEnabled bool clipConfigEnabled bool
clipConfigDisableHistory bool
clipConfigEnableHistory bool
) )
var clipExportCmd = &cobra.Command{
Use: "export [file]",
Short: "Export clipboard history to JSON",
Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.",
Run: runClipExport,
}
var clipImportCmd = &cobra.Command{
Use: "import <file>",
Short: "Import clipboard history from JSON",
Long: "Import clipboard history from JSON file exported by 'dms cl export'.",
Args: cobra.ExactArgs(1),
Run: runClipImport,
}
var clipMigrateCmd = &cobra.Command{
Use: "cliphist-migrate [db-path]",
Short: "Migrate from cliphist",
Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.",
Run: runClipMigrate,
}
var clipMigrateDelete bool
func init() { func init() {
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type") clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type")
clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file")
clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON")
clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "C", false, "Copy entry to clipboard") clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "c", false, "Copy entry to clipboard")
clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results") clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results")
clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset") clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset")
@@ -207,24 +167,23 @@ func init() {
clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)") clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)")
clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup") clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup")
clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup") clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup")
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard tracking") clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard manager entirely")
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking") clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard manager")
clipConfigSetCmd.Flags().BoolVar(&clipConfigDisableHistory, "disable-history", false, "Disable clipboard history persistence")
clipConfigSetCmd.Flags().BoolVar(&clipConfigEnableHistory, "enable-history", false, "Enable clipboard history persistence")
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)") clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd) clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd)
} }
func runClipCopy(cmd *cobra.Command, args []string) { func runClipCopy(cmd *cobra.Command, args []string) {
var data []byte var data []byte
switch { if len(args) > 0 {
case len(args) > 0:
data = []byte(args[0]) data = []byte(args[0])
default: } else {
var err error var err error
data, err = io.ReadAll(os.Stdin) data, err = io.ReadAll(os.Stdin)
if err != nil { if err != nil {
@@ -232,67 +191,11 @@ func runClipCopy(cmd *cobra.Command, args []string) {
} }
} }
if clipCopyDownload {
filePath, err := downloadToTempFile(strings.TrimSpace(string(data)))
if err != nil {
log.Fatalf("download: %v", err)
}
if err := copyFileToClipboard(filePath); err != nil {
log.Fatalf("copy file: %v", err)
}
return
}
if clipCopyType == "__multi__" {
offers, err := parseMultiOffers(data)
if err != nil {
log.Fatalf("parse multi offers: %v", err)
}
if err := clipboard.CopyMulti(offers, true, clipCopyPasteOnce); err != nil {
log.Fatalf("copy multi: %v", err)
}
return
}
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
log.Fatalf("copy: %v", err) log.Fatalf("copy: %v", err)
} }
} }
func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
var offers []clipboard.Offer
pos := 0
for pos < len(data) {
mimeEnd := bytes.IndexByte(data[pos:], 0)
if mimeEnd == -1 {
break
}
mimeType := string(data[pos : pos+mimeEnd])
pos += mimeEnd + 1
lenEnd := bytes.IndexByte(data[pos:], 0)
if lenEnd == -1 {
break
}
dataLen, err := strconv.Atoi(string(data[pos : pos+lenEnd]))
if err != nil {
return nil, fmt.Errorf("parse length: %w", err)
}
pos += lenEnd + 1
if pos+dataLen > len(data) {
return nil, fmt.Errorf("data truncated")
}
offerData := data[pos : pos+dataLen]
pos += dataLen
offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
}
return offers, nil
}
func runClipPaste(cmd *cobra.Command, args []string) { func runClipPaste(cmd *cobra.Command, args []string) {
data, _, err := clipboard.Paste() data, _, err := clipboard.Paste()
if err != nil { if err != nil {
@@ -449,13 +352,16 @@ func runClipGet(cmd *cobra.Command, args []string) {
req := models.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.copyEntry", Method: "clipboard.copyEntry",
Params: map[string]any{"id": id}, Params: map[string]any{
"id": id,
},
} }
resp, err := sendServerRequest(req) resp, err := sendServerRequest(req)
if err != nil { if err != nil {
log.Fatalf("Failed to copy clipboard entry: %v", err) log.Fatalf("Failed to copy clipboard entry: %v", err)
} }
if resp.Error != "" { if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error) log.Fatalf("Error: %s", resp.Error)
} }
@@ -681,6 +587,12 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
if clipConfigEnabled { if clipConfigEnabled {
params["disabled"] = false params["disabled"] = false
} }
if clipConfigDisableHistory {
params["disableHistory"] = true
}
if clipConfigEnableHistory {
params["disableHistory"] = false
}
if len(params) == 0 { if len(params) == 0 {
fmt.Println("No config options specified") fmt.Println("No config options specified")
@@ -704,230 +616,3 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
fmt.Println("Config updated") fmt.Println("Config updated")
} }
func runClipExport(cmd *cobra.Command, args []string) {
req := models.Request{
ID: 1,
Method: "clipboard.getHistory",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get clipboard history: %v", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
if resp.Result == nil {
log.Fatal("No clipboard history")
}
out, err := json.MarshalIndent(resp.Result, "", " ")
if err != nil {
log.Fatalf("Failed to marshal: %v", err)
}
if len(args) == 0 {
fmt.Println(string(out))
return
}
if err := os.WriteFile(args[0], out, 0o644); err != nil {
log.Fatalf("Failed to write file: %v", err)
}
fmt.Printf("Exported to %s\n", args[0])
}
func runClipImport(cmd *cobra.Command, args []string) {
data, err := os.ReadFile(args[0])
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}
var entries []map[string]any
if err := json.Unmarshal(data, &entries); err != nil {
log.Fatalf("Failed to parse JSON: %v", err)
}
var imported int
for _, entry := range entries {
dataStr, ok := entry["data"].(string)
if !ok {
continue
}
mimeType, _ := entry["mimeType"].(string)
if mimeType == "" {
mimeType = "text/plain"
}
var entryData []byte
if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil {
entryData = decoded
} else {
entryData = []byte(dataStr)
}
if err := clipboard.Store(entryData, mimeType); err != nil {
log.Errorf("Failed to store entry: %v", err)
continue
}
imported++
}
fmt.Printf("Imported %d entries\n", imported)
}
func runClipMigrate(cmd *cobra.Command, args []string) {
dbPath := getCliphistPath()
if len(args) > 0 {
dbPath = args[0]
}
if _, err := os.Stat(dbPath); err != nil {
log.Fatalf("Cliphist db not found: %s", dbPath)
}
db, err := bolt.Open(dbPath, 0o644, &bolt.Options{
ReadOnly: true,
Timeout: 1 * time.Second,
})
if err != nil {
log.Fatalf("Failed to open cliphist db: %v", err)
}
defer db.Close()
var migrated int
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("b"))
if b == nil {
return fmt.Errorf("cliphist bucket not found")
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
if len(v) == 0 {
continue
}
mimeType := detectMimeType(v)
if err := clipboard.Store(v, mimeType); err != nil {
log.Errorf("Failed to store entry %d: %v", btoi(k), err)
continue
}
migrated++
}
return nil
})
if err != nil {
log.Fatalf("Migration failed: %v", err)
}
fmt.Printf("Migrated %d entries from cliphist\n", migrated)
if !clipMigrateDelete {
return
}
db.Close()
if err := os.Remove(dbPath); err != nil {
log.Errorf("Failed to delete cliphist db: %v", err)
return
}
os.Remove(filepath.Dir(dbPath))
fmt.Println("Deleted cliphist db")
}
func getCliphistPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db")
}
return filepath.Join(cacheDir, "cliphist", "db")
}
func detectMimeType(data []byte) string {
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
return "image/png"
}
return "text/plain"
}
func btoi(v []byte) uint64 {
return binary.BigEndian.Uint64(v)
}
func downloadToTempFile(rawURL string) (string, error) {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return "", fmt.Errorf("invalid URL: %s", rawURL)
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("parse URL: %w", err)
}
ext := filepath.Ext(parsedURL.Path)
if ext == "" {
ext = ".png"
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(rawURL)
if err != nil {
return "", fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx != -1 {
contentType = strings.TrimSpace(contentType[:idx])
}
if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") {
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
return "", fmt.Errorf("not a valid media file (content-type: %s)", contentType)
}
}
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = "/tmp"
}
clipDir := filepath.Join(cacheDir, "dms", "clipboard")
if err := os.MkdirAll(clipDir, 0o755); err != nil {
return "", fmt.Errorf("create cache dir: %w", err)
}
filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
if err := os.WriteFile(filePath, data, 0o644); err != nil {
return "", fmt.Errorf("write file: %w", err)
}
return filePath, nil
}
func copyFileToClipboard(filePath string) error {
req := models.Request{
ID: 1,
Method: "clipboard.copyFile",
Params: map[string]any{"filePath": filePath},
}
resp, err := sendServerRequest(req)
if err != nil {
return fmt.Errorf("server request: %w", err)
}
if resp.Error != "" {
return fmt.Errorf("server error: %s", resp.Error)
}
return nil
}

View File

@@ -64,8 +64,9 @@ var killCmd = &cobra.Command{
} }
var ipcCmd = &cobra.Command{ var ipcCmd = &cobra.Command{
Use: "ipc [target] [function] [args...]", Use: "ipc",
Short: "Send IPC commands to running DMS shell", Short: "Send IPC commands to running DMS shell",
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
PreRunE: findConfig, PreRunE: findConfig,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = findConfig(cmd, args) _ = findConfig(cmd, args)
@@ -76,13 +77,6 @@ var ipcCmd = &cobra.Command{
}, },
} }
func init() {
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
_ = findConfig(cmd, args)
printIPCHelp()
})
}
var debugSrvCmd = &cobra.Command{ var debugSrvCmd = &cobra.Command{
Use: "debug-srv", Use: "debug-srv",
Short: "Start the debug server", Short: "Start the debug server",
@@ -517,12 +511,8 @@ func getCommonCommands() []*cobra.Command {
colorCmd, colorCmd,
screenshotCmd, screenshotCmd,
notifyActionCmd, notifyActionCmd,
notifyCmd,
genericNotifyActionCmd,
matugenCmd, matugenCmd,
clipboardCmd, clipboardCmd,
chromaCmd,
doctorCmd, doctorCmd,
configCmd,
} }
} }

View File

@@ -1,318 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Configuration utilities",
}
var resolveIncludeCmd = &cobra.Command{
Use: "resolve-include <compositor> <filename>",
Short: "Check if a file is included in compositor config",
Long: "Recursively check if a file is included/sourced in compositor configuration. Returns JSON with exists and included status.",
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
switch len(args) {
case 0:
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
case 1:
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runResolveInclude,
}
func init() {
configCmd.AddCommand(resolveIncludeCmd)
}
type IncludeResult struct {
Exists bool `json:"exists"`
Included bool `json:"included"`
}
func runResolveInclude(cmd *cobra.Command, args []string) {
compositor := strings.ToLower(args[0])
filename := args[1]
var result IncludeResult
var err error
switch compositor {
case "hyprland":
result, err = checkHyprlandInclude(filename)
case "niri":
result, err = checkNiriInclude(filename)
case "mangowc", "dwl", "mango":
result, err = checkMangoWCInclude(filename)
default:
log.Fatalf("Unknown compositor: %s", compositor)
}
if err != nil {
log.Fatalf("Error checking include: %v", err)
}
output, _ := json.Marshal(result)
fmt.Fprintln(os.Stdout, string(output))
}
func checkHyprlandInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
continue
}
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if hyprlandFindInclude(expanded, target, processed) {
return true
}
}
return false
}
func checkNiriInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "config.kdl")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func niriFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
content := string(data)
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "include") {
continue
}
startQuote := strings.Index(trimmed, "\"")
if startQuote == -1 {
continue
}
endQuote := strings.LastIndex(trimmed, "\"")
if endQuote <= startQuote {
continue
}
includePath := trimmed[startQuote+1 : endQuote]
if matchesTarget(includePath, target) {
return true
}
fullPath := includePath
if !filepath.IsAbs(includePath) {
fullPath = filepath.Join(baseDir, includePath)
}
if niriFindInclude(fullPath, target, processed) {
return true
}
}
return false
}
func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/mango")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "config.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
mainConfig = filepath.Join(configDir, "mango.conf")
}
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func mangowcFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
continue
}
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if mangowcFindInclude(expanded, target, processed) {
return true
}
}
return false
}
func matchesTarget(path, target string) bool {
path = strings.TrimPrefix(path, "./")
target = strings.TrimPrefix(target, "./")
return path == target || strings.HasSuffix(path, "/"+target)
}

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -13,84 +12,12 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui" "github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type status string
const (
statusOK status = "ok"
statusWarn status = "warn"
statusError status = "error"
statusInfo status = "info"
)
func (s status) IconStyle(styles tui.Styles) (string, lipgloss.Style) {
switch s {
case statusOK:
return "●", styles.Success
case statusWarn:
return "●", styles.Warning
case statusError:
return "●", styles.Error
default:
return "○", styles.Subtle
}
}
type DoctorStatus struct {
Errors []checkResult
Warnings []checkResult
OK []checkResult
Info []checkResult
}
func (ds *DoctorStatus) Add(r checkResult) {
switch r.status {
case statusError:
ds.Errors = append(ds.Errors, r)
case statusWarn:
ds.Warnings = append(ds.Warnings, r)
case statusOK:
ds.OK = append(ds.OK, r)
case statusInfo:
ds.Info = append(ds.Info, r)
}
}
func (ds *DoctorStatus) HasIssues() bool {
return len(ds.Errors) > 0 || len(ds.Warnings) > 0
}
func (ds *DoctorStatus) ErrorCount() int {
return len(ds.Errors)
}
func (ds *DoctorStatus) WarningCount() int {
return len(ds.Warnings)
}
func (ds *DoctorStatus) OKCount() int {
return len(ds.OK)
}
var (
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
)
var doctorCmd = &cobra.Command{ var doctorCmd = &cobra.Command{
Use: "doctor", Use: "doctor",
Short: "Diagnose DMS installation and dependencies", Short: "Diagnose DMS installation and dependencies",
@@ -98,14 +25,10 @@ var doctorCmd = &cobra.Command{
Run: runDoctor, Run: runDoctor,
} }
var ( var doctorVerbose bool
doctorVerbose bool
doctorJSON bool
)
func init() { func init() {
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions") doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
} }
type category int type category int
@@ -119,82 +42,23 @@ const (
catOptionalFeatures catOptionalFeatures
catConfigFiles catConfigFiles
catServices catServices
catEnvironment
) )
func (c category) String() string { var categoryNames = []string{
switch c { "System", "Versions", "Installation", "Compositor",
case catSystem: "Quickshell Features", "Optional Features", "Config Files", "Services",
return "System"
case catVersions:
return "Versions"
case catInstallation:
return "Installation"
case catCompositor:
return "Compositor"
case catQuickshellFeatures:
return "Quickshell Features"
case catOptionalFeatures:
return "Optional Features"
case catConfigFiles:
return "Config Files"
case catServices:
return "Services"
case catEnvironment:
return "Environment"
default:
return "Unknown"
}
} }
const (
checkNameMaxLength = 21
doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
)
type checkResult struct { type checkResult struct {
category category category category
name string name string
status status status string
message string message string
details string details string
url string
}
type checkResultJSON struct {
Category string `json:"category"`
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
URL string `json:"url,omitempty"`
}
type doctorOutputJSON struct {
Summary struct {
Errors int `json:"errors"`
Warnings int `json:"warnings"`
OK int `json:"ok"`
Info int `json:"info"`
} `json:"summary"`
Results []checkResultJSON `json:"results"`
}
func (r checkResult) toJSON() checkResultJSON {
return checkResultJSON{
Category: r.category.String(),
Name: r.name,
Status: string(r.status),
Message: r.message,
Details: r.details,
URL: r.url,
}
} }
func runDoctor(cmd *cobra.Command, args []string) { func runDoctor(cmd *cobra.Command, args []string) {
if !doctorJSON { printDoctorHeader()
printDoctorHeader()
}
qsFeatures, qsMissingFeatures := checkQuickshellFeatures() qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
@@ -207,15 +71,10 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkOptionalDependencies(), checkOptionalDependencies(),
checkConfigurationFiles(), checkConfigurationFiles(),
checkSystemdServices(), checkSystemdServices(),
checkEnvironmentVars(),
) )
if doctorJSON { printResults(results)
printResultsJSON(results) printSummary(results, qsMissingFeatures)
} else {
printResults(results)
printSummary(results, qsMissingFeatures)
}
} }
func printDoctorHeader() { func printDoctorHeader() {
@@ -229,52 +88,50 @@ func printDoctorHeader() {
} }
func checkSystemInfo() []checkResult { func checkSystemInfo() []checkResult {
var results []checkResult results := []checkResult{}
osInfo, err := distros.GetOSInfo() osInfo, err := distros.GetOSInfo()
if err != nil { if err != nil {
status, message, details := statusWarn, fmt.Sprintf("Unknown (%v)", err), "" status, message, details := "warn", fmt.Sprintf("Unknown (%v)", err), ""
if strings.Contains(err.Error(), "Unsupported distribution") { if strings.Contains(err.Error(), "Unsupported distribution") {
osRelease := readOSRelease() osRelease := readOSRelease()
switch { if osRelease["ID"] == "nixos" {
case osRelease["ID"] == "nixos": status = "ok"
status = statusOK
message = osRelease["PRETTY_NAME"] message = osRelease["PRETTY_NAME"]
if message == "" { if message == "" {
message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"]) message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"])
} }
details = "Supported for runtime (install via NixOS module or Flake)" details = "Supported for runtime (install via NixOS module or Flake)"
case osRelease["PRETTY_NAME"] != "": } else if osRelease["PRETTY_NAME"] != "" {
message = fmt.Sprintf("%s (not supported by dms setup)", osRelease["PRETTY_NAME"]) message = fmt.Sprintf("%s (not supported by dms setup)", osRelease["PRETTY_NAME"])
details = "DMS may work but automatic installation is not available" details = "DMS may work but automatic installation is not available"
} }
} }
results = append(results, checkResult{catSystem, "Operating System", status, message, details, doctorDocsURL + "#operating-system"}) results = append(results, checkResult{catSystem, "Operating System", status, message, details})
} else { } else {
status := statusOK status := "ok"
message := osInfo.PrettyName message := osInfo.PrettyName
if message == "" { if message == "" {
message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID) message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID)
} }
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
status = statusWarn status = "warn"
message += " (version may not be fully supported)" message += " (version may not be fully supported)"
} }
results = append(results, checkResult{ results = append(results, checkResult{
catSystem, "Operating System", status, message, catSystem, "Operating System", status, message,
fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture), fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture),
doctorDocsURL + "#operating-system",
}) })
} }
arch := runtime.GOARCH arch := runtime.GOARCH
archStatus := statusOK archStatus := "ok"
if arch != "amd64" && arch != "arm64" { if arch != "amd64" && arch != "arm64" {
archStatus = statusError archStatus = "error"
} }
results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, "", doctorDocsURL + "#architecture"}) results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, ""})
waylandDisplay := os.Getenv("WAYLAND_DISPLAY") waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
xdgSessionType := os.Getenv("XDG_SESSION_TYPE") xdgSessionType := os.Getenv("XDG_SESSION_TYPE")
@@ -282,41 +139,21 @@ func checkSystemInfo() []checkResult {
switch { switch {
case waylandDisplay != "" || xdgSessionType == "wayland": case waylandDisplay != "" || xdgSessionType == "wayland":
results = append(results, checkResult{ results = append(results, checkResult{
catSystem, "Display Server", statusOK, "Wayland", catSystem, "Display Server", "ok", "Wayland",
fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay), fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay),
doctorDocsURL + "#display-server",
}) })
case xdgSessionType == "x11": case xdgSessionType == "x11":
results = append(results, checkResult{catSystem, "Display Server", statusError, "X11 (DMS requires Wayland)", "", doctorDocsURL + "#display-server"}) results = append(results, checkResult{catSystem, "Display Server", "error", "X11 (DMS requires Wayland)", ""})
default: default:
results = append(results, checkResult{ results = append(results, checkResult{
catSystem, "Display Server", statusWarn, "Unknown (ensure you're running Wayland)", catSystem, "Display Server", "warn", "Unknown (ensure you're running Wayland)",
fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType), fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType),
doctorDocsURL + "#display-server",
}) })
} }
return results return results
} }
func checkEnvironmentVars() []checkResult {
var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkEnvVar("QS_ICON_THEME")...)
return results
}
func checkEnvVar(name string) []checkResult {
value := os.Getenv(name)
if value != "" {
return []checkResult{{catEnvironment, name, statusInfo, value, "", doctorDocsURL + "#environment-variables"}}
}
if doctorVerbose {
return []checkResult{{catEnvironment, name, statusInfo, "Not set", "", doctorDocsURL + "#environment-variables"}}
}
return nil
}
func readOSRelease() map[string]string { func readOSRelease() map[string]string {
result := make(map[string]string) result := make(map[string]string)
data, err := os.ReadFile("/etc/os-release") data, err := os.ReadFile("/etc/os-release")
@@ -332,28 +169,18 @@ func readOSRelease() map[string]string {
} }
func checkVersions(qsMissingFeatures bool) []checkResult { func checkVersions(qsMissingFeatures bool) []checkResult {
dmsCliPath, _ := os.Executable()
dmsCliDetails := ""
if doctorVerbose {
dmsCliDetails = dmsCliPath
}
results := []checkResult{ results := []checkResult{
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"}, {catVersions, "DMS CLI", "info", formatVersion(Version), ""},
} }
qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures) qsVersion, qsStatus := getQuickshellVersionInfo(qsMissingFeatures)
qsDetails := "" results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, ""})
if doctorVerbose && qsPath != "" {
qsDetails = qsPath
}
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails, doctorDocsURL + "#quickshell"})
dmsVersion, dmsPath := getDMSShellVersion() dmsVersion, dmsPath := getDMSShellVersion()
if dmsVersion != "" { if dmsVersion != "" {
results = append(results, checkResult{catVersions, "DMS Shell", statusOK, dmsVersion, dmsPath, doctorDocsURL + "#dms-shell"}) results = append(results, checkResult{catVersions, "DMS Shell", "ok", dmsVersion, dmsPath})
} else { } else {
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"}) results = append(results, checkResult{catVersions, "DMS Shell", "error", "Not installed or not detected", "Run 'dms setup' to install"})
} }
return results return results
@@ -379,34 +206,32 @@ func getDMSShellVersion() (version, path string) {
return "", "" return "", ""
} }
func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) { func getQuickshellVersionInfo(missingFeatures bool) (string, string) {
if !utils.CommandExists("qs") { if !utils.CommandExists("qs") {
return "Not installed", statusError, "" return "Not installed", "error"
} }
qsPath, _ := exec.LookPath("qs")
output, err := exec.Command("qs", "--version").Output() output, err := exec.Command("qs", "--version").Output()
if err != nil { if err != nil {
return "Installed (version check failed)", statusWarn, qsPath return "Installed (version check failed)", "warn"
} }
fullVersion := strings.TrimSpace(string(output)) fullVersion := strings.TrimSpace(string(output))
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 { if matches := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`).FindStringSubmatch(fullVersion); len(matches) >= 2 {
if version.CompareVersions(matches[1], "0.2.0") < 0 { if version.CompareVersions(matches[1], "0.2.0") < 0 {
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), "error"
} }
if missingFeatures { if missingFeatures {
return fullVersion, statusWarn, qsPath return fullVersion, "warn"
} }
return fullVersion, statusOK, qsPath return fullVersion, "ok"
} }
return fullVersion, statusWarn, qsPath return fullVersion, "warn"
} }
func checkDMSInstallation() []checkResult { func checkDMSInstallation() []checkResult {
var results []checkResult results := []checkResult{}
dmsPath := "" dmsPath := ""
if err := findConfig(nil, nil); err == nil && configPath != "" { if err := findConfig(nil, nil); err == nil && configPath != "" {
@@ -416,16 +241,16 @@ func checkDMSInstallation() []checkResult {
} }
if dmsPath == "" { if dmsPath == "" {
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}} return []checkResult{{catInstallation, "DMS Configuration", "error", "Not found", "shell.qml not found in any config path"}}
} }
results = append(results, checkResult{catInstallation, "DMS Configuration", statusOK, "Found", dmsPath, doctorDocsURL + "#dms-configuration"}) results = append(results, checkResult{catInstallation, "DMS Configuration", "ok", "Found", dmsPath})
shellQml := filepath.Join(dmsPath, "shell.qml") shellQml := filepath.Join(dmsPath, "shell.qml")
if _, err := os.Stat(shellQml); err != nil { if _, err := os.Stat(shellQml); err != nil {
results = append(results, checkResult{catInstallation, "shell.qml", statusError, "Missing", shellQml, doctorDocsURL + "#dms-configuration"}) results = append(results, checkResult{catInstallation, "shell.qml", "error", "Missing", shellQml})
} else { } else {
results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml, doctorDocsURL + "#dms-configuration"}) results = append(results, checkResult{catInstallation, "shell.qml", "ok", "Present", shellQml})
} }
if doctorVerbose { if doctorVerbose {
@@ -438,7 +263,7 @@ func checkDMSInstallation() []checkResult {
case strings.Contains(dmsPath, ".config"): case strings.Contains(dmsPath, ".config"):
installType = "User config" installType = "User config"
} }
results = append(results, checkResult{catInstallation, "Install Type", statusInfo, installType, dmsPath, doctorDocsURL + "#dms-configuration"}) results = append(results, checkResult{catInstallation, "Install Type", "info", installType, dmsPath})
} }
return results return results
@@ -446,69 +271,52 @@ func checkDMSInstallation() []checkResult {
func checkWindowManagers() []checkResult { func checkWindowManagers() []checkResult {
compositors := []struct { compositors := []struct {
name, versionCmd, versionArg string name, versionCmd, versionArg, versionRe string
versionRegex *regexp.Regexp commands []string
commands []string
}{ }{
{"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}}, {"Hyprland", "hyprctl", "version", `v?(\d+\.\d+\.\d+)`, []string{"hyprland", "Hyprland"}},
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}}, {"niri", "niri", "--version", `niri (\d+\.\d+)`, []string{"niri"}},
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}}, {"Sway", "sway", "--version", `sway version (\d+\.\d+)`, []string{"sway"}},
{"River", "river", "-version", riverVersionRegex, []string{"river"}}, {"River", "river", "-version", `river (\d+\.\d+)`, []string{"river"}},
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}}, {"Wayfire", "wayfire", "--version", `wayfire (\d+\.\d+)`, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
} }
var results []checkResult results := []checkResult{}
foundAny := false foundAny := false
for _, c := range compositors { for _, c := range compositors {
if !slices.ContainsFunc(c.commands, utils.CommandExists) { if slices.ContainsFunc(c.commands, utils.CommandExists) {
continue foundAny = true
results = append(results, checkResult{
catCompositor, c.name, "ok",
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRe), "",
})
} }
foundAny = true
var compositorPath string
for _, cmd := range c.commands {
if path, err := exec.LookPath(cmd); err == nil {
compositorPath = path
break
}
}
details := ""
if doctorVerbose && compositorPath != "" {
details = compositorPath
}
results = append(results, checkResult{
catCompositor, c.name, statusOK,
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
doctorDocsURL + "#compositor-checks",
})
} }
if !foundAny { if !foundAny {
results = append(results, checkResult{ results = append(results, checkResult{
catCompositor, "Compositor", statusError, catCompositor, "Compositor", "error",
"No supported Wayland compositor found", "No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire", "Install Hyprland, niri, Sway, River, or Wayfire",
doctorDocsURL + "#compositor-checks",
}) })
} }
if wm := detectRunningWM(); wm != "" { if wm := detectRunningWM(); wm != "" {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"}) results = append(results, checkResult{catCompositor, "Active", "info", wm, ""})
} }
return results return results
} }
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string { func getVersionFromCommand(cmd, arg, regex string) string {
output, err := exec.Command(cmd, arg).CombinedOutput() output, err := exec.Command(cmd, arg).Output()
if err != nil && len(output) == 0 { if err != nil {
return "installed" return "installed"
} }
outStr := string(output) outStr := string(output)
if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 { if matches := regexp.MustCompile(regex).FindStringSubmatch(outStr); len(matches) > 1 {
ver := matches[1] ver := matches[1]
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
return ver + " (git)" return ver + " (git)"
@@ -591,7 +399,7 @@ ShellRoot {
} }
` `
if err := os.WriteFile(testScript, []byte(qmlContent), 0o644); err != nil { if err := os.WriteFile(testScript, []byte(qmlContent), 0644); err != nil {
return nil, false return nil, false
} }
@@ -607,122 +415,80 @@ ShellRoot {
{"ShortcutInhibitor", "Allow shortcut management (niri)"}, {"ShortcutInhibitor", "Allow shortcut management (niri)"},
} }
var results []checkResult results := []checkResult{}
missingFeatures := false missingFeatures := false
for _, f := range features { for _, f := range features {
available := strings.Contains(outputStr, fmt.Sprintf("FEATURE:%s:OK", f.name)) available := strings.Contains(outputStr, fmt.Sprintf("FEATURE:%s:OK", f.name))
status, message := statusOK, "Available" status, message := "ok", "Available"
if !available { if !available {
status, message = statusInfo, "Not available" status, message = "info", "Not available"
missingFeatures = true missingFeatures = true
} }
results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc, doctorDocsURL + "#quickshell-features"}) results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc})
} }
return results, missingFeatures return results, missingFeatures
} }
func checkI2CAvailability() checkResult {
ddc, err := brightness.NewDDCBackend()
if err != nil {
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control", doctorDocsURL + "#optional-features"}
}
defer ddc.Close()
devices, err := ddc.GetDevices()
if err != nil || len(devices) == 0 {
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "No monitors detected", "External monitor brightness control", doctorDocsURL + "#optional-features"}
}
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
}
func detectNetworkBackend(stackResult *network.DetectResult) string {
switch stackResult.Backend {
case network.BackendNetworkManager:
return "NetworkManager"
case network.BackendIwd:
return "iwd"
case network.BackendNetworkd:
if stackResult.HasIwd {
return "iwd + systemd-networkd"
}
return "systemd-networkd"
case network.BackendConnMan:
return "ConnMan"
default:
return ""
}
}
func getOptionalDBusStatus(busName string) (status, string) {
if utils.IsDBusServiceAvailable(busName) {
return statusOK, "Available"
} else {
return statusWarn, "Not available"
}
}
func checkOptionalDependencies() []checkResult { func checkOptionalDependencies() []checkResult {
var results []checkResult results := []checkResult{}
optionalFeaturesURL := doctorDocsURL + "#optional-features" if utils.IsServiceActive("accounts-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", "ok", "Running", "User accounts"})
accountsStatus, accountsMsg := getOptionalDBusStatus("org.freedesktop.Accounts") } else {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", accountsStatus, accountsMsg, "User accounts", optionalFeaturesURL}) results = append(results, checkResult{catOptionalFeatures, "accountsservice", "warn", "Not running", "User accounts"})
}
ppdStatus, ppdMsg := getOptionalDBusStatus("org.freedesktop.UPower.PowerProfiles")
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", ppdStatus, ppdMsg, "Power profile management", optionalFeaturesURL})
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL})
results = append(results, checkI2CAvailability())
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"} terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 { terminalFound := ""
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL}) for _, term := range terminals {
} else { if utils.CommandExists(term) {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL}) terminalFound = term
} break
networkResult, err := network.DetectNetworkStack()
networkStatus, networkMessage, networkDetails := statusOK, "Not available", "Network management"
if err == nil && networkResult.Backend != network.BackendNone {
networkMessage = detectNetworkBackend(networkResult)
if doctorVerbose {
networkDetails = networkResult.ChosenReason
} }
} else {
networkStatus = statusInfo
} }
if terminalFound != "" {
results = append(results, checkResult{catOptionalFeatures, "Network", networkStatus, networkMessage, networkDetails, optionalFeaturesURL}) results = append(results, checkResult{catOptionalFeatures, "Terminal", "ok", terminalFound, ""})
} else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", "warn", "None found", "Install ghostty, kitty, or alacritty"})
}
deps := []struct { deps := []struct {
name, cmd, desc string name, cmd, altCmd, desc string
important bool important bool
}{ }{
{"matugen", "matugen", "Dynamic theming", true}, {"matugen", "matugen", "", "Dynamic theming", true},
{"dgop", "dgop", "System monitoring", true}, {"dgop", "dgop", "", "System monitoring", true},
{"cava", "cava", "Audio visualizer", true}, {"cava", "cava", "", "Audio waveform", false},
{"khal", "khal", "Calendar events", false}, {"khal", "khal", "", "Calendar events", false},
{"danksearch", "dsearch", "File search", false}, {"Network", "nmcli", "iwctl", "Network management", false},
{"fprintd", "fprintd-list", "Fingerprint auth", false}, {"danksearch", "dsearch", "", "File search", false},
{"loginctl", "loginctl", "", "Session management", false},
{"fprintd", "fprintd-list", "", "Fingerprint auth", false},
} }
for _, d := range deps { for _, d := range deps {
found := utils.CommandExists(d.cmd) found, foundCmd := utils.CommandExists(d.cmd), d.cmd
if !found && d.altCmd != "" {
if utils.CommandExists(d.altCmd) {
found, foundCmd = true, d.altCmd
}
}
switch { if found {
case found: message := "Installed"
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, "Installed", d.desc, optionalFeaturesURL}) switch foundCmd {
case d.important: case "nmcli":
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, optionalFeaturesURL}) message = "NetworkManager"
default: case "iwctl":
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, optionalFeaturesURL}) message = "iwd"
}
results = append(results, checkResult{catOptionalFeatures, d.name, "ok", message, d.desc})
} else if d.important {
results = append(results, checkResult{catOptionalFeatures, d.name, "warn", "Missing", d.desc})
} else {
results = append(results, checkResult{catOptionalFeatures, d.name, "info", "Not installed", d.desc})
} }
} }
@@ -730,33 +496,19 @@ func checkOptionalDependencies() []checkResult {
} }
func checkConfigurationFiles() []checkResult { func checkConfigurationFiles() []checkResult {
configDir, _ := os.UserConfigDir()
cacheDir, _ := os.UserCacheDir()
dmsDir := "DankMaterialShell"
configFiles := []struct{ name, path string }{ configFiles := []struct{ name, path string }{
{"settings.json", filepath.Join(configDir, dmsDir, "settings.json")}, {"Settings", filepath.Join(utils.XDGConfigHome(), "DankMaterialShell", "settings.json")},
{"clsettings.json", filepath.Join(configDir, dmsDir, "clsettings.json")}, {"Session", filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "session.json")},
{"plugin_settings.json", filepath.Join(configDir, dmsDir, "plugin_settings.json")}, {"Colors", filepath.Join(utils.XDGCacheHome(), "DankMaterialShell", "dms-colors.json")},
{"session.json", filepath.Join(utils.XDGStateHome(), dmsDir, "session.json")},
{"dms-colors.json", filepath.Join(cacheDir, dmsDir, "dms-colors.json")},
} }
var results []checkResult results := []checkResult{}
for _, cf := range configFiles { for _, cf := range configFiles {
info, err := os.Stat(cf.path) if _, err := os.Stat(cf.path); err == nil {
if err != nil { results = append(results, checkResult{catConfigFiles, cf.name, "ok", "Present", cf.path})
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path, doctorDocsURL + "#config-files"}) } else {
continue results = append(results, checkResult{catConfigFiles, cf.name, "info", "Not yet created", cf.path})
} }
status := statusOK
message := "Present"
if info.Mode().Perm()&0o200 == 0 {
status = statusWarn
message += " (read-only)"
}
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path, doctorDocsURL + "#config-files"})
} }
return results return results
} }
@@ -766,35 +518,31 @@ func checkSystemdServices() []checkResult {
return nil return nil
} }
var results []checkResult results := []checkResult{}
dmsState := getServiceState("dms", true) dmsState := getServiceState("dms", true)
if !dmsState.exists { if !dmsState.exists {
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "dms.service", "info", "Not installed", "Optional user service"})
} else { } else {
status, message := statusOK, dmsState.enabled status, message := "ok", dmsState.enabled
if dmsState.active != "" { if dmsState.active != "" {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active) message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
} }
switch { if dmsState.enabled == "disabled" {
case dmsState.enabled == "disabled": status, message = "warn", "Disabled"
status, message = statusWarn, "Disabled"
case dmsState.active == "failed" || dmsState.active == "inactive":
status = statusError
} }
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "dms.service", status, message, ""})
} }
greetdState := getServiceState("greetd", false) greetdState := getServiceState("greetd", false)
switch { if greetdState.exists {
case greetdState.exists: status := "ok"
status := statusOK
if greetdState.enabled == "disabled" { if greetdState.enabled == "disabled" {
status = statusInfo status = "info"
} }
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, ""})
case doctorVerbose: } else if doctorVerbose {
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "greetd", "info", "Not installed", "Optional greeter service"})
} }
return results return results
@@ -841,85 +589,67 @@ func printResults(results []checkResult) {
if currentCategory != -1 { if currentCategory != -1 {
fmt.Println() fmt.Println()
} }
fmt.Printf(" %s\n", styles.Bold.Render(r.category.String())) fmt.Printf(" %s\n", styles.Bold.Render(categoryNames[r.category]))
currentCategory = r.category currentCategory = r.category
} }
printResultLine(r, styles) printResultLine(r, styles)
} }
} }
func printResultsJSON(results []checkResult) {
var ds DoctorStatus
for _, r := range results {
ds.Add(r)
}
output := doctorOutputJSON{}
output.Summary.Errors = ds.ErrorCount()
output.Summary.Warnings = ds.WarningCount()
output.Summary.OK = ds.OKCount()
output.Summary.Info = len(ds.Info)
output.Results = make([]checkResultJSON, 0, len(results))
for _, r := range results {
output.Results = append(output.Results, r.toJSON())
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(output); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
os.Exit(1)
}
}
func printResultLine(r checkResult, styles tui.Styles) { func printResultLine(r checkResult, styles tui.Styles) {
icon, style := r.status.IconStyle(styles) icon, style := "○", styles.Subtle
switch r.status {
case "ok":
icon, style = "●", styles.Success
case "warn":
icon, style = "●", styles.Warning
case "error":
icon, style = "●", styles.Error
}
name := r.name name := r.name
nameLen := len(name) if len(name) > 18 {
name = name[:17] + "…"
if nameLen > checkNameMaxLength {
name = name[:checkNameMaxLength-1] + "…"
nameLen = checkNameMaxLength
} }
dots := strings.Repeat("·", checkNameMaxLength-nameLen) dots := strings.Repeat("·", 19-len(name))
fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message) fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message)
if doctorVerbose && r.details != "" { if doctorVerbose && r.details != "" {
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details)) fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
} }
if (r.status == statusError || r.status == statusWarn) && r.url != "" {
fmt.Printf(" %s\n", styles.Subtle.Render("→ "+r.url))
}
} }
func printSummary(results []checkResult, qsMissingFeatures bool) { func printSummary(results []checkResult, qsMissingFeatures bool) {
theme := tui.TerminalTheme() theme := tui.TerminalTheme()
styles := tui.NewStyles(theme) styles := tui.NewStyles(theme)
var ds DoctorStatus errors, warnings, ok := 0, 0, 0
for _, r := range results { for _, r := range results {
ds.Add(r) switch r.status {
case "error":
errors++
case "warn":
warnings++
case "ok":
ok++
}
} }
fmt.Println() fmt.Println()
fmt.Printf(" %s\n", styles.Subtle.Render("──────────────────────────────────────")) fmt.Printf(" %s\n", styles.Subtle.Render("──────────────────────────────────────"))
if !ds.HasIssues() { if errors == 0 && warnings == 0 {
fmt.Printf(" %s\n", styles.Success.Render("✓ All checks passed!")) fmt.Printf(" %s\n", styles.Success.Render("✓ All checks passed!"))
} else { } else {
var parts []string parts := []string{}
if errors > 0 {
if ds.ErrorCount() > 0 { parts = append(parts, styles.Error.Render(fmt.Sprintf("%d error(s)", errors)))
parts = append(parts, styles.Error.Render(fmt.Sprintf("%d error(s)", ds.ErrorCount())))
} }
if ds.WarningCount() > 0 { if warnings > 0 {
parts = append(parts, styles.Warning.Render(fmt.Sprintf("%d warning(s)", ds.WarningCount()))) parts = append(parts, styles.Warning.Render(fmt.Sprintf("%d warning(s)", warnings)))
} }
parts = append(parts, styles.Success.Render(fmt.Sprintf("%d ok", ds.OKCount()))) parts = append(parts, styles.Success.Render(fmt.Sprintf("%d ok", ok)))
fmt.Printf(" %s\n", strings.Join(parts, ", ")) fmt.Printf(" %s\n", strings.Join(parts, ", "))
if qsMissingFeatures { if qsMissingFeatures {

View File

@@ -377,7 +377,7 @@ func updateDMSBinary() error {
} }
version := "" version := ""
for line := range strings.SplitSeq(string(output), "\n") { for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") { if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"") parts := strings.Split(line, "\"")
if len(parts) >= 4 { if len(parts) >= 4 {
@@ -443,7 +443,7 @@ func updateDMSBinary() error {
decompressedPath := filepath.Join(tempDir, "dms") decompressedPath := filepath.Join(tempDir, "dms")
if err := os.Chmod(decompressedPath, 0o755); err != nil { if err := os.Chmod(decompressedPath, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err) return fmt.Errorf("failed to make binary executable: %w", err)
} }

View File

@@ -211,8 +211,8 @@ func checkGroupExists(groupName string) bool {
return false return false
} }
lines := strings.SplitSeq(string(data), "\n") lines := strings.Split(string(data), "\n")
for line := range lines { for _, line := range lines {
if strings.HasPrefix(line, groupName+":") { if strings.HasPrefix(line, groupName+":") {
return true return true
} }
@@ -521,7 +521,7 @@ func enableGreeter() error {
newConfig := strings.Join(finalLines, "\n") newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml" tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0o644); err != nil { if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err) return fmt.Errorf("failed to write temp config: %w", err)
} }
@@ -592,8 +592,8 @@ func checkGreeterStatus() error {
if data, err := os.ReadFile(configPath); err == nil { if data, err := os.ReadFile(configPath); err == nil {
configContent := string(data) configContent := string(data)
if strings.Contains(configContent, "dms-greeter") { if strings.Contains(configContent, "dms-greeter") {
lines := strings.SplitSeq(configContent, "\n") lines := strings.Split(configContent, "\n")
for line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") { if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
parts := strings.SplitN(trimmed, "=", 2) parts := strings.SplitN(trimmed, "=", 2)

View File

@@ -57,14 +57,12 @@ var keybindsRemoveCmd = &cobra.Command{
} }
func init() { func init() {
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay") keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked") keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds") keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat") keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)") keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
keybindsCmd.AddCommand(keybindsListCmd) keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd) keybindsCmd.AddCommand(keybindsShowCmd)
@@ -112,21 +110,12 @@ func initializeProviders() {
} }
} }
func runKeybindsList(cmd *cobra.Command, _ []string) { func runKeybindsList(_ *cobra.Command, _ []string) {
providerList := keybinds.GetDefaultRegistry().List() providerList := keybinds.GetDefaultRegistry().List()
asJSON, _ := cmd.Flags().GetBool("json")
if asJSON {
output, _ := json.Marshal(providerList)
fmt.Fprintln(os.Stdout, string(output))
return
}
if len(providerList) == 0 { if len(providerList) == 0 {
fmt.Fprintln(os.Stdout, "No providers available") fmt.Fprintln(os.Stdout, "No providers available")
return return
} }
fmt.Fprintln(os.Stdout, "Available providers:") fmt.Fprintln(os.Stdout, "Available providers:")
for _, name := range providerList { for _, name := range providerList {
fmt.Fprintf(os.Stdout, " - %s\n", name) fmt.Fprintf(os.Stdout, " - %s\n", name)
@@ -212,9 +201,6 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
if v, _ := cmd.Flags().GetBool("no-repeat"); v { if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false options["repeat"] = false
} }
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
desc, _ := cmd.Flags().GetString("desc") desc, _ := cmd.Flags().GetString("desc")
if err := writable.SetBind(key, action, desc, options); err != nil { if err := writable.SetBind(key, action, desc, options); err != nil {

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -30,16 +29,9 @@ var matugenQueueCmd = &cobra.Command{
Run: runMatugenQueue, Run: runMatugenQueue,
} }
var matugenCheckCmd = &cobra.Command{
Use: "check",
Short: "Check which template apps are detected",
Run: runMatugenCheck,
}
func init() { func init() {
matugenCmd.AddCommand(matugenGenerateCmd) matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd) matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} { for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files") cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -82,7 +74,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
ConfigDir: configDir, ConfigDir: configDir,
Kind: kind, Kind: kind,
Value: value, Value: value,
Mode: matugen.ColorMode(mode), Mode: mode,
IconTheme: iconTheme, IconTheme: iconTheme,
MatugenType: matugenType, MatugenType: matugenType,
RunUserTemplates: runUserTemplates, RunUserTemplates: runUserTemplates,
@@ -170,12 +162,3 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
log.Fatalf("Timeout waiting for theme generation") log.Fatalf("Timeout waiting for theme generation")
} }
} }
func runMatugenCheck(cmd *cobra.Command, args []string) {
checks := matugen.CheckTemplates(nil)
data, err := json.Marshal(checks)
if err != nil {
log.Fatalf("Failed to marshal check results: %v", err)
}
fmt.Println(string(data))
}

View File

@@ -1,68 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/spf13/cobra"
)
var (
notifyAppName string
notifyIcon string
notifyFile string
notifyTimeout int
)
var notifyCmd = &cobra.Command{
Use: "notify <summary> [body]",
Short: "Send a desktop notification",
Long: `Send a desktop notification with optional actions.
If --file is provided, the notification will have "Open" and "Open Folder" actions.
Examples:
dms notify "Hello" "World"
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
Args: cobra.MinimumNArgs(1),
Run: runNotify,
}
var genericNotifyActionCmd = &cobra.Command{
Use: "notify-action-generic",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
notify.RunActionListener(args)
},
}
func init() {
notifyCmd.Flags().StringVar(&notifyAppName, "app", "DMS", "Application name")
notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
notifyCmd.Flags().IntVar(&notifyTimeout, "timeout", 5000, "Timeout in milliseconds")
}
func runNotify(cmd *cobra.Command, args []string) {
summary := args[0]
body := ""
if len(args) > 1 {
body = args[1]
}
n := notify.Notification{
AppName: notifyAppName,
Icon: notifyIcon,
Summary: summary,
Body: body,
FilePath: notifyFile,
Timeout: int32(notifyTimeout),
}
if err := notify.Send(n); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

View File

@@ -7,7 +7,9 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dms"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -18,9 +20,11 @@ var rootCmd = &cobra.Command{
Use: "dms", Use: "dms",
Short: "dms CLI", Short: "dms CLI",
Long: "dms is the DankMaterialShell management CLI and backend server.", Long: "dms is the DankMaterialShell management CLI and backend server.",
Run: runInteractiveMode,
} }
func init() { func init() {
// Add the -c flag
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory") rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
} }
@@ -34,7 +38,7 @@ func findConfig(cmd *cobra.Command, args []string) error {
if statErr == nil && !info.IsDir() { if statErr == nil && !info.IsDir() {
configPath = customConfigPath configPath = customConfigPath
log.Debug("Using config from: %s", configPath) log.Debug("Using config from: %s", configPath)
return nil return nil // <-- Guard statement
} }
if statErr != nil { if statErr != nil {
@@ -46,18 +50,15 @@ func findConfig(cmd *cobra.Command, args []string) error {
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if data, readErr := os.ReadFile(configStateFile); readErr == nil { if data, readErr := os.ReadFile(configStateFile); readErr == nil {
if len(getAllDMSPIDs()) == 0 { statePath := strings.TrimSpace(string(data))
os.Remove(configStateFile) shellPath := filepath.Join(statePath, "shell.qml")
} else {
statePath := strings.TrimSpace(string(data))
shellPath := filepath.Join(statePath, "shell.qml")
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() { if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
log.Debug("Using config from active session state file: %s", statePath) log.Debug("Using config from active session state file: %s", statePath)
configPath = statePath configPath = statePath
log.Debug("Using config from: %s", configPath) log.Debug("Using config from: %s", configPath)
return nil return nil // <-- Guard statement
} } else {
os.Remove(configStateFile) os.Remove(configStateFile)
} }
} }
@@ -72,3 +73,18 @@ func findConfig(cmd *cobra.Command, args []string) error {
log.Debug("Using config from: %s", configPath) log.Debug("Using config from: %s", configPath)
return nil return nil
} }
func runInteractiveMode(cmd *cobra.Command, args []string) {
detector, _ := dms.NewDetector()
if !detector.IsDMSInstalled() {
log.Error("DankMaterialShell (DMS) is not detected as installed on this system.")
log.Info("Please install DMS using dankinstall before using this management interface.")
os.Exit(1)
}
model := dms.NewModel(Version)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("Error running program: %v", err)
}
}

View File

@@ -87,14 +87,20 @@ func newDPMSClient() (*dpmsClient, error) {
switch e.Interface { switch e.Interface {
case wlr_output_power.ZwlrOutputPowerManagerV1InterfaceName: case wlr_output_power.ZwlrOutputPowerManagerV1InterfaceName:
powerMgr := wlr_output_power.NewZwlrOutputPowerManagerV1(c.ctx) powerMgr := wlr_output_power.NewZwlrOutputPowerManagerV1(c.ctx)
version := min(e.Version, 1) version := e.Version
if version > 1 {
version = 1
}
if err := registry.Bind(e.Name, e.Interface, version, powerMgr); err == nil { if err := registry.Bind(e.Name, e.Interface, version, powerMgr); err == nil {
c.powerMgr = powerMgr c.powerMgr = powerMgr
} }
case "wl_output": case "wl_output":
output := wlclient.NewOutput(c.ctx) output := wlclient.NewOutput(c.ctx)
version := min(e.Version, 4) version := e.Version
if version > 4 {
version = 4
}
if err := registry.Bind(e.Name, e.Interface, version, output); err == nil { if err := registry.Bind(e.Name, e.Interface, version, output); err == nil {
outputID := fmt.Sprintf("output-%d", output.ID()) outputID := fmt.Sprintf("output-%d", output.ID())
state := &outputState{ state := &outputState{

View File

@@ -7,10 +7,8 @@ import (
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -186,10 +184,8 @@ func runShellInteractive(session bool) {
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath) cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if os.Getenv("QT_LOGGING_RULES") == "" { if qtRules := log.GetQtLoggingRules(); qtRules != "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" { cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
} }
if isSessionManaged && hasSystemdRun() { if isSessionManaged && hasSystemdRun() {
@@ -375,7 +371,13 @@ func killShell() {
func runShellDaemon(session bool) { func runShellDaemon(session bool) {
isSessionManaged = session isSessionManaged = session
isDaemonChild := slices.Contains(os.Args, "--daemon-child") isDaemonChild := false
for _, arg := range os.Args {
if arg == "--daemon-child" {
isDaemonChild = true
break
}
}
if !isDaemonChild { if !isDaemonChild {
fmt.Fprintf(os.Stderr, "dms %s\n", Version) fmt.Fprintf(os.Stderr, "dms %s\n", Version)
@@ -426,10 +428,8 @@ func runShellDaemon(session bool) {
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath) cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if os.Getenv("QT_LOGGING_RULES") == "" { if qtRules := log.GetQtLoggingRules(); qtRules != "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" { cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
} }
if isSessionManaged && hasSystemdRun() { if isSessionManaged && hasSystemdRun() {
@@ -531,20 +531,12 @@ func runShellDaemon(session bool) {
} }
} }
var qsHasAnyDisplay = sync.OnceValue(func() bool {
out, err := exec.Command("qs", "ipc", "--help").Output()
if err != nil {
return false
}
return strings.Contains(string(out), "--any-display")
})
func parseTargetsFromIPCShowOutput(output string) ipcTargets { func parseTargetsFromIPCShowOutput(output string) ipcTargets {
targets := make(ipcTargets) targets := make(ipcTargets)
var currentTarget string var currentTarget string
for line := range strings.SplitSeq(output, "\n") { for _, line := range strings.Split(output, "\n") {
if after, ok := strings.CutPrefix(line, "target "); ok { if strings.HasPrefix(line, "target ") {
currentTarget = strings.TrimSpace(after) currentTarget = strings.TrimSpace(strings.TrimPrefix(line, "target "))
targets[currentTarget] = make(map[string][]string) targets[currentTarget] = make(map[string][]string)
} }
if strings.HasPrefix(line, " function") && currentTarget != "" { if strings.HasPrefix(line, " function") && currentTarget != "" {
@@ -569,11 +561,7 @@ func parseTargetsFromIPCShowOutput(output string) ipcTargets {
} }
func getShellIPCCompletions(args []string, _ string) []string { func getShellIPCCompletions(args []string, _ string) []string {
cmdArgs := []string{"ipc"} cmdArgs := []string{"-p", configPath, "ipc", "show"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...) cmd := exec.Command("qs", cmdArgs...)
var targets ipcTargets var targets ipcTargets
@@ -618,20 +606,16 @@ func getShellIPCCompletions(args []string, _ string) []string {
func runShellIPCCommand(args []string) { func runShellIPCCommand(args []string) {
if len(args) == 0 { if len(args) == 0 {
printIPCHelp() log.Error("IPC command requires arguments")
return log.Info("Usage: dms ipc <command> [args...]")
os.Exit(1)
} }
if args[0] != "call" { if args[0] != "call" {
args = append([]string{"call"}, args...) args = append([]string{"call"}, args...)
} }
cmdArgs := []string{"ipc"} cmdArgs := append([]string{"-p", configPath, "ipc"}, args...)
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("qs", cmdArgs...) cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@@ -641,45 +625,3 @@ func runShellIPCCommand(args []string) {
log.Fatalf("Error running IPC command: %v", err) log.Fatalf("Error running IPC command: %v", err)
} }
} }
func printIPCHelp() {
fmt.Println("Usage: dms ipc <target> <function> [args...]")
fmt.Println()
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...)
output, err := cmd.Output()
if err != nil {
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
return
}
targets := parseTargetsFromIPCShowOutput(string(output))
if len(targets) == 0 {
fmt.Println("No IPC targets available")
return
}
fmt.Println("Targets:")
targetNames := make([]string, 0, len(targets))
for name := range targets {
targetNames = append(targetNames, name)
}
slices.Sort(targetNames)
for _, targetName := range targetNames {
funcs := targets[targetName]
funcNames := make([]string, 0, len(funcs))
for fn := range funcs {
funcNames = append(funcNames, fn)
}
slices.Sort(funcNames)
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
}
}

View File

@@ -3,7 +3,6 @@ package main
import ( import (
"fmt" "fmt"
"os/exec" "os/exec"
"slices"
"strings" "strings"
) )
@@ -37,7 +36,13 @@ func checkSystemdServiceEnabled(serviceName string) (string, bool, error) {
if err != nil { if err != nil {
knownStates := []string{"disabled", "masked", "masked-runtime", "not-found", "enabled", "enabled-runtime", "static", "indirect", "alias"} knownStates := []string{"disabled", "masked", "masked-runtime", "not-found", "enabled", "enabled-runtime", "static", "indirect", "alias"}
isKnownState := slices.Contains(knownStates, stateStr) isKnownState := false
for _, known := range knownStates {
if stateStr == known {
isKnownState = true
break
}
}
if !isKnownState { if !isKnownState {
return stateStr, false, fmt.Errorf("systemctl is-enabled failed: %w (output: %s)", err, stateStr) return stateStr, false, fmt.Errorf("systemctl is-enabled failed: %w (output: %s)", err, stateStr)

View File

@@ -4,37 +4,33 @@ go 1.24.6
require ( require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0 github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.0
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/pilebones/go-udev v0.9.1 github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
golang.org/x/image v0.35.0 golang.org/x/image v0.34.0
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.8.0 // indirect github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc // indirect github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -42,21 +38,21 @@ require (
github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.47.0 // indirect
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.4 // indirect github.com/charmbracelet/x/ansi v0.11.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9 github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -70,7 +66,7 @@ require (
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.40.0 golang.org/x/sys v0.38.0
golang.org/x/text v0.33.0 golang.org/x/text v0.32.0
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -4,14 +4,6 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U= github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 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/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 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@@ -24,38 +16,36 @@ github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 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/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI= github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ= github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -66,24 +56,22 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY= github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 h1:eY5aB2GXiVdgTueBcqsBt53WuJTRZAuCdIS/86Pcq5c=
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc= github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII= github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw= 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-20260123133532-f99a98e81ce9 h1:VzdR70t+SMjYnBgnbtNpq4ElZAAovLPMG+GFX8OBRtM= github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 h1:jxQ3BzYeErNRvlI/4+0mpwqMzvB4g97U+ksfgvrUEbY=
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s= github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU= github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -124,14 +112,14 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 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/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E= github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 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/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 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -139,43 +127,35 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -330,163 +330,3 @@ func selectPreferredMimeType(mimes []string) string {
func IsImageMimeType(mime string) bool { func IsImageMimeType(mime string) bool {
return len(mime) > 6 && mime[:6] == "image/" return len(mime) > 6 && mime[:6] == "image/"
} }
type Offer struct {
MimeType string
Data []byte
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if !foreground {
return copyMultiFork(offers, pasteOnce)
}
return copyMultiServe(offers, pasteOnce)
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
}
func copyMultiServe(offers []Offer, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
}
defer display.Destroy()
ctx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
defer device.Destroy()
source, err := dataControlMgr.CreateDataSource()
if err != nil {
return fmt.Errorf("create data source: %w", err)
}
offerMap := make(map[string][]byte)
for _, offer := range offers {
if err := source.Offer(offer.MimeType); err != nil {
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
}
offerMap[offer.MimeType] = offer.Data
}
cancelled := make(chan struct{})
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
defer syscall.Close(e.Fd)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
file.Write(data)
}
select {
case pasted <- struct{}{}:
default:
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
close(cancelled)
})
if err := device.SetSelection(source); err != nil {
return fmt.Errorf("set selection: %w", err)
}
display.Roundtrip()
for {
select {
case <-cancelled:
return nil
case <-pasted:
if pasteOnce {
return nil
}
default:
if err := ctx.Dispatch(); err != nil {
return nil
}
}
}
}

View File

@@ -55,12 +55,12 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize) return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize)
} }
dbPath, err := GetDBPath() dbPath, err := getDBPath()
if err != nil { if err != nil {
return fmt.Errorf("get db path: %w", err) return fmt.Errorf("get db path: %w", err)
} }
db, err := bolt.Open(dbPath, 0o644, &bolt.Options{Timeout: 1 * time.Second}) db, err := bolt.Open(dbPath, 0644, &bolt.Options{Timeout: 1 * time.Second})
if err != nil { if err != nil {
return fmt.Errorf("open db: %w", err) return fmt.Errorf("open db: %w", err)
} }
@@ -111,7 +111,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
}) })
} }
func GetDBPath() (string, error) { func getDBPath() (string, error) {
cacheDir, err := os.UserCacheDir() cacheDir, err := os.UserCacheDir()
if err != nil { if err != nil {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
@@ -121,31 +121,12 @@ func GetDBPath() (string, error) {
cacheDir = filepath.Join(homeDir, ".cache") cacheDir = filepath.Join(homeDir, ".cache")
} }
newDir := filepath.Join(cacheDir, "DankMaterialShell", "clipboard") dbDir := filepath.Join(cacheDir, "dms-clipboard")
newPath := filepath.Join(newDir, "db") if err := os.MkdirAll(dbDir, 0700); err != nil {
if _, err := os.Stat(newPath); err == nil {
return newPath, nil
}
oldDir := filepath.Join(cacheDir, "dms-clipboard")
oldPath := filepath.Join(oldDir, "db")
if _, err := os.Stat(oldPath); err == nil {
if err := os.MkdirAll(newDir, 0o700); err != nil {
return "", err
}
if err := os.Rename(oldPath, newPath); err != nil {
return "", err
}
os.Remove(oldDir)
return newPath, nil
}
if err := os.MkdirAll(newDir, 0o700); err != nil {
return "", err return "", err
} }
return newPath, nil
return filepath.Join(dbDir, "db"), nil
} }
func deduplicateInTx(b *bolt.Bucket, hash uint64) error { func deduplicateInTx(b *bolt.Bucket, hash uint64) error {

View File

@@ -221,7 +221,10 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
case client.OutputInterfaceName: case client.OutputInterfaceName:
output := client.NewOutput(p.ctx) output := client.NewOutput(p.ctx)
version := min(e.Version, 4) version := e.Version
if version > 4 {
version = 4
}
if err := p.registry.Bind(e.Name, e.Interface, version, output); err == nil { if err := p.registry.Bind(e.Name, e.Interface, version, output); err == nil {
p.outputsMu.Lock() p.outputsMu.Lock()
p.outputs[e.Name] = &Output{ p.outputs[e.Name] = &Output{
@@ -236,14 +239,20 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
case wlr_layer_shell.ZwlrLayerShellV1InterfaceName: case wlr_layer_shell.ZwlrLayerShellV1InterfaceName:
layerShell := wlr_layer_shell.NewZwlrLayerShellV1(p.ctx) layerShell := wlr_layer_shell.NewZwlrLayerShellV1(p.ctx)
version := min(e.Version, 4) version := e.Version
if version > 4 {
version = 4
}
if err := p.registry.Bind(e.Name, e.Interface, version, layerShell); err == nil { if err := p.registry.Bind(e.Name, e.Interface, version, layerShell); err == nil {
p.layerShell = layerShell p.layerShell = layerShell
} }
case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName: case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName:
screencopy := wlr_screencopy.NewZwlrScreencopyManagerV1(p.ctx) screencopy := wlr_screencopy.NewZwlrScreencopyManagerV1(p.ctx)
version := min(e.Version, 3) version := e.Version
if version > 3 {
version = 3
}
if err := p.registry.Bind(e.Name, e.Interface, version, screencopy); err == nil { if err := p.registry.Bind(e.Name, e.Interface, version, screencopy); err == nil {
p.screencopy = screencopy p.screencopy = screencopy
} }

View File

@@ -1157,7 +1157,7 @@ func drawGlyph(data []byte, stride, width, height, x, y int, r rune, col Color,
rOff, bOff = 2, 0 rOff, bOff = 2, 0
} }
for row := range fontH { for row := 0; row < fontH; row++ {
yy := y + row yy := y + row
if yy < 0 || yy >= height { if yy < 0 || yy >= height {
continue continue
@@ -1165,7 +1165,7 @@ func drawGlyph(data []byte, stride, width, height, x, y int, r rune, col Color,
rowPattern := g[row] rowPattern := g[row]
dstRowOff := yy * stride dstRowOff := yy * stride
for colIdx := range fontW { for colIdx := 0; colIdx < fontW; colIdx++ {
if (rowPattern & (1 << (fontW - 1 - colIdx))) == 0 { if (rowPattern & (1 << (fontW - 1 - colIdx))) == 0 {
continue continue
} }

View File

@@ -14,11 +14,11 @@ func TestSurfaceState_ConcurrentPointerMotion(t *testing.T) {
const goroutines = 50 const goroutines = 50
const iterations = 100 const iterations = 100
for i := range goroutines { for i := 0; i < goroutines; i++ {
wg.Add(1) wg.Add(1)
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
for j := range iterations { for j := 0; j < iterations; j++ {
s.OnPointerMotion(float64(id*10+j), float64(id*10+j)) s.OnPointerMotion(float64(id*10+j), float64(id*10+j))
} }
}(i) }(i)
@@ -34,21 +34,21 @@ func TestSurfaceState_ConcurrentScaleAccess(t *testing.T) {
const goroutines = 30 const goroutines = 30
const iterations = 100 const iterations = 100
for i := range goroutines / 2 { for i := 0; i < goroutines/2; i++ {
wg.Add(1) wg.Add(1)
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
s.SetScale(int32(id%3 + 1)) s.SetScale(int32(id%3 + 1))
} }
}(i) }(i)
} }
for range goroutines / 2 { for i := 0; i < goroutines/2; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
scale := s.Scale() scale := s.Scale()
assert.GreaterOrEqual(t, scale, int32(1)) assert.GreaterOrEqual(t, scale, int32(1))
} }
@@ -65,21 +65,21 @@ func TestSurfaceState_ConcurrentLogicalSize(t *testing.T) {
const goroutines = 20 const goroutines = 20
const iterations = 100 const iterations = 100
for i := range goroutines / 2 { for i := 0; i < goroutines/2; i++ {
wg.Add(1) wg.Add(1)
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
for j := range iterations { for j := 0; j < iterations; j++ {
_ = s.OnLayerConfigure(1920+id, 1080+j) _ = s.OnLayerConfigure(1920+id, 1080+j)
} }
}(i) }(i)
} }
for range goroutines / 2 { for i := 0; i < goroutines/2; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
w, h := s.LogicalSize() w, h := s.LogicalSize()
_ = w _ = w
_ = h _ = h
@@ -97,31 +97,31 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
const goroutines = 30 const goroutines = 30
const iterations = 100 const iterations = 100
for range goroutines / 3 { for i := 0; i < goroutines/3; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
s.OnPointerButton(0x110, 1) s.OnPointerButton(0x110, 1)
} }
}() }()
} }
for range goroutines / 3 { for i := 0; i < goroutines/3; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
s.OnKey(1, 1) s.OnKey(1, 1)
} }
}() }()
} }
for range goroutines / 3 { for i := 0; i < goroutines/3; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
picked, cancelled := s.IsDone() picked, cancelled := s.IsDone()
_ = picked _ = picked
_ = cancelled _ = cancelled
@@ -139,11 +139,11 @@ func TestSurfaceState_ConcurrentIsReady(t *testing.T) {
const goroutines = 20 const goroutines = 20
const iterations = 100 const iterations = 100
for range goroutines { for i := 0; i < goroutines; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
_ = s.IsReady() _ = s.IsReady()
} }
}() }()
@@ -159,11 +159,11 @@ func TestSurfaceState_ConcurrentSwapBuffers(t *testing.T) {
const goroutines = 20 const goroutines = 20
const iterations = 100 const iterations = 100
for range goroutines { for i := 0; i < goroutines; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for range iterations { for j := 0; j < iterations; j++ {
s.SwapBuffers() s.SwapBuffers()
} }
}() }()

View File

@@ -126,13 +126,13 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
} }
configDir := filepath.Dir(result.Path) configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err) result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error return result, result.Error
} }
dmsDir := filepath.Join(configDir, "dms") dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err) result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error return result, result.Error
} }
@@ -150,7 +150,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil { if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err) result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error return result, result.Error
} }
@@ -176,7 +176,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
} }
if existingConfig != "" { if existingConfig != "" {
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig, dmsDir) mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig)
if err != nil { if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err)) cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err))
} else { } else {
@@ -185,7 +185,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
} }
} }
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err) result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error return result, result.Error
} }
@@ -209,18 +209,16 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"layout.kdl", NiriLayoutConfig}, {"layout.kdl", NiriLayoutConfig},
{"alttab.kdl", NiriAlttabConfig}, {"alttab.kdl", NiriAlttabConfig},
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, {"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
} }
for _, cfg := range configs { for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name) path := filepath.Join(dmsDir, cfg.name)
// Skip if file already exists and is not empty to preserve user modifications // Skip if file already exists to preserve user modifications
if info, err := os.Stat(path); err == nil && info.Size() > 0 { if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue continue
} }
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil { if err := os.WriteFile(path, []byte(cfg.content), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err) return fmt.Errorf("failed to write %s: %w", cfg.name, err)
} }
cd.log(fmt.Sprintf("Deployed %s", cfg.name)) cd.log(fmt.Sprintf("Deployed %s", cfg.name))
@@ -238,7 +236,7 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
} }
configDir := filepath.Dir(mainResult.Path) configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -254,14 +252,14 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil { if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err) mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
} }
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0o644); err != nil { if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err) mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -276,12 +274,12 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
} }
themesDir := filepath.Dir(colorResult.Path) themesDir := filepath.Dir(colorResult.Path)
if err := os.MkdirAll(themesDir, 0o755); err != nil { if err := os.MkdirAll(themesDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create themes directory: %w", err) mainResult.Error = fmt.Errorf("failed to create themes directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0o644); err != nil { if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0644); err != nil {
colorResult.Error = fmt.Errorf("failed to write color config: %w", err) colorResult.Error = fmt.Errorf("failed to write color config: %w", err)
return results, colorResult.Error return results, colorResult.Error
} }
@@ -302,7 +300,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
} }
configDir := filepath.Dir(mainResult.Path) configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -318,14 +316,14 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil { if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err) mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
} }
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0o644); err != nil { if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err) mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -339,7 +337,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"), Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"),
} }
if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0o644); err != nil { if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err) themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error return results, themeResult.Error
} }
@@ -353,7 +351,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"), Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"),
} }
if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0o644); err != nil { if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0644); err != nil {
tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err) tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err)
return results, tabsResult.Error return results, tabsResult.Error
} }
@@ -374,7 +372,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
} }
configDir := filepath.Dir(mainResult.Path) configDir := filepath.Dir(mainResult.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) mainResult.Error = fmt.Errorf("failed to create config directory: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -390,14 +388,14 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
mainResult.BackupPath = mainResult.Path + ".backup." + timestamp mainResult.BackupPath = mainResult.Path + ".backup." + timestamp
if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil { if err := os.WriteFile(mainResult.BackupPath, existingData, 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to create backup: %w", err) mainResult.Error = fmt.Errorf("failed to create backup: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
} }
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0o644); err != nil { if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err) mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error return []DeploymentResult{mainResult}, mainResult.Error
} }
@@ -411,7 +409,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"), Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"),
} }
if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0o644); err != nil { if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0644); err != nil {
themeResult.Error = fmt.Errorf("failed to write theme config: %w", err) themeResult.Error = fmt.Errorf("failed to write theme config: %w", err)
return results, themeResult.Error return results, themeResult.Error
} }
@@ -423,31 +421,24 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
return results, nil return results, nil
} }
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dmsDir string) (string, error) { // 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*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
// Find all output sections in the existing config
existingOutputs := outputRegex.FindAllString(existingConfig, -1) existingOutputs := outputRegex.FindAllString(existingConfig, -1)
if len(existingOutputs) == 0 { if len(existingOutputs) == 0 {
// No output sections to merge
return newConfig, nil return newConfig, nil
} }
outputsPath := filepath.Join(dmsDir, "outputs.kdl") // Remove the example output section from the new config
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, output := range existingOutputs {
outputsContent.WriteString(output)
outputsContent.WriteString("\n\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err))
} else {
cd.log("Migrated output sections to dms/outputs.kdl")
}
}
exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "") mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "")
// Find where to insert the output sections (after the input section)
inputEndRegex := regexp.MustCompile(`(?m)^}$`) inputEndRegex := regexp.MustCompile(`(?m)^}$`)
inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1) inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1)
@@ -455,6 +446,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
return "", fmt.Errorf("could not find insertion point for output sections") 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] insertPos := inputMatches[0][1]
var builder strings.Builder var builder strings.Builder
@@ -479,17 +471,11 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
} }
configDir := filepath.Dir(result.Path) configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err) result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error return result, result.Error
} }
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var existingConfig string var existingConfig string
if _, err := os.Stat(result.Path); err == nil { if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration") cd.log("Found existing Hyprland configuration")
@@ -503,7 +489,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil { if err := os.WriteFile(result.BackupPath, existingData, 0644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err) result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error return result, result.Error
} }
@@ -529,7 +515,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
} }
if existingConfig != "" { if existingConfig != "" {
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir) mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig)
if err != nil { if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err)) cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
} else { } else {
@@ -538,50 +524,18 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
} }
} }
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err) result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error return result, result.Error
} }
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true result.Deployed = true
cd.log("Successfully deployed Hyprland configuration") cd.log("Successfully deployed Hyprland configuration")
return result, nil return result, nil
} }
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error { // mergeHyprlandMonitorSections extracts monitor sections from existing config and merges them into the new config
configs := []struct { func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig string) (string, error) {
name string
content string
}{
{"colors.conf", HyprColorsConfig},
{"layout.conf", HyprLayoutConfig},
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.conf", ""},
{"cursor.conf", ""},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
// Skip if file already exists and is not empty to preserve user modifications
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`) monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
existingMonitors := monitorRegex.FindAllString(existingConfig, -1) existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
@@ -589,20 +543,6 @@ func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig
return newConfig, nil return newConfig, nil
} }
outputsPath := filepath.Join(dmsDir, "outputs.conf")
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, monitor := range existingMonitors {
outputsContent.WriteString(monitor)
outputsContent.WriteString("\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
} else {
cd.log("Migrated monitor sections to dms/outputs.conf")
}
}
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`) exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "") mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")

View File

@@ -161,8 +161,7 @@ layout {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir() result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig)
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError { if tt.wantError {
assert.Error(t, err) assert.Error(t, err)
@@ -220,9 +219,9 @@ func TestConfigDeploymentFlow(t *testing.T) {
t.Run("deploy ghostty config with existing file", func(t *testing.T) { t.Run("deploy ghostty config with existing file", func(t *testing.T) {
existingContent := "# Old config\nfont-size = 14\n" existingContent := "# Old config\nfont-size = 14\n"
ghosttyPath := getGhosttyPath() ghosttyPath := getGhosttyPath()
err := os.MkdirAll(filepath.Dir(ghosttyPath), 0o755) err := os.MkdirAll(filepath.Dir(ghosttyPath), 0755)
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0o644) err = os.WriteFile(ghosttyPath, []byte(existingContent), 0644)
require.NoError(t, err) require.NoError(t, err)
results, err := cd.deployGhosttyConfig() results, err := cd.deployGhosttyConfig()
@@ -363,8 +362,7 @@ input {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir() result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig)
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError { if tt.wantError {
assert.Error(t, err) assert.Error(t, err)
@@ -408,7 +406,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path) content, err := os.ReadFile(result.Path)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG") assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "source = ./dms/binds.conf") assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty")
assert.Contains(t, string(content), "exec-once = ") assert.Contains(t, string(content), "exec-once = ")
}) })
@@ -422,9 +420,9 @@ general {
} }
` `
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf") hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755) err := os.MkdirAll(filepath.Dir(hyprPath), 0755)
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644) err = os.WriteFile(hyprPath, []byte(existingContent), 0644)
require.NoError(t, err) require.NoError(t, err)
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true) result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
@@ -444,7 +442,7 @@ general {
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144") 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), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "source = ./dms/binds.conf") assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty")
assert.NotContains(t, string(newContent), "monitor = eDP-2") assert.NotContains(t, string(newContent), "monitor = eDP-2")
}) })
} }
@@ -461,7 +459,10 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG") assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS") assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG") assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf") assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec, {{TERMINAL_COMMAND}}")
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) { func TestGhosttyConfigStructure(t *testing.T) {
@@ -600,9 +601,9 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
t.Run("deploy alacritty config with existing file", func(t *testing.T) { t.Run("deploy alacritty config with existing file", func(t *testing.T) {
existingContent := "# Old alacritty config\n[window]\nopacity = 0.9\n" existingContent := "# Old alacritty config\n[window]\nopacity = 0.9\n"
alacrittyPath := filepath.Join(tempDir, ".config", "alacritty", "alacritty.toml") alacrittyPath := filepath.Join(tempDir, ".config", "alacritty", "alacritty.toml")
err := os.MkdirAll(filepath.Dir(alacrittyPath), 0o755) err := os.MkdirAll(filepath.Dir(alacrittyPath), 0755)
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0o644) err = os.WriteFile(alacrittyPath, []byte(existingContent), 0644)
require.NoError(t, err) require.NoError(t, err)
results, err := cd.deployAlacrittyConfig() results, err := cd.deployAlacrittyConfig()

View File

@@ -21,7 +21,7 @@ func LocateDMSConfig() (string, error) {
dataDirs = "/usr/local/share:/usr/share" dataDirs = "/usr/local/share:/usr/share"
} }
for dir := range strings.SplitSeq(dataDirs, ":") { for _, dir := range strings.Split(dataDirs, ":") {
if dir != "" { if dir != "" {
primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms")) primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms"))
} }
@@ -33,7 +33,7 @@ func LocateDMSConfig() (string, error) {
configDirs = "/etc/xdg" configDirs = "/etc/xdg"
} }
for dir := range strings.SplitSeq(configDirs, ":") { for _, dir := range strings.Split(configDirs, ":") {
if dir != "" { if dir != "" {
primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms")) primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms"))
} }

View File

@@ -1,159 +0,0 @@
# === Application Launchers ===
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
bind = SUPER, space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
bind = SUPER, X, exec, dms ipc call powermenu toggle
# === Cheat sheet
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = SUPER ALT, L, exec, dms ipc call lock lock
bind = SUPER SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === 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
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
bind = SUPER, down, movefocus, d
bind = SUPER, up, movefocus, u
bind = SUPER, right, movefocus, r
bind = SUPER, H, movefocus, l
bind = SUPER, J, movefocus, d
bind = SUPER, K, movefocus, u
bind = SUPER, L, movefocus, r
# === Window Movement ===
bind = SUPER SHIFT, left, movewindow, l
bind = SUPER SHIFT, down, movewindow, d
bind = SUPER SHIFT, up, movewindow, u
bind = SUPER SHIFT, right, movewindow, r
bind = SUPER SHIFT, H, movewindow, l
bind = SUPER SHIFT, J, movewindow, d
bind = SUPER SHIFT, K, movewindow, u
bind = SUPER SHIFT, L, movewindow, r
# === Column Navigation ===
bind = SUPER, Home, focuswindow, first
bind = SUPER, End, focuswindow, last
# === Monitor Navigation ===
bind = SUPER CTRL, left, focusmonitor, l
bind = SUPER CTRL, right, focusmonitor, r
bind = SUPER CTRL, H, focusmonitor, l
bind = SUPER CTRL, J, focusmonitor, d
bind = SUPER CTRL, K, focusmonitor, u
bind = SUPER CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = SUPER, Page_Down, workspace, e+1
bind = SUPER, Page_Up, workspace, e-1
bind = SUPER, U, workspace, e+1
bind = SUPER, I, workspace, e-1
bind = SUPER CTRL, down, movetoworkspace, e+1
bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Workspace Management ===
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
bind = SUPER SHIFT, U, movetoworkspace, e+1
bind = SUPER SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
bind = SUPER, 6, workspace, 6
bind = SUPER, 7, workspace, 7
bind = SUPER, 8, workspace, 8
bind = SUPER, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 6, movetoworkspace, 6
bind = SUPER SHIFT, 7, movetoworkspace, 7
bind = SUPER SHIFT, 8, movetoworkspace, 8
bind = SUPER SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = SUPER, bracketleft, layoutmsg, preselect l
bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow
bindmd = SUPER, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = SUPER, minus, resizeactive, -10% 0
binde = SUPER, equal, resizeactive, 10% 0
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
binde = SUPER SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle

View File

@@ -1,25 +0,0 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb(d0bcff)
$outline = rgb(948f99)
$error = rgb(f2b8b5)
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}

View File

@@ -1,11 +0,0 @@
# Auto-generated by DMS - do not edit manually
general {
gaps_in = 4
gaps_out = 4
border_size = 2
}
decoration {
rounding = 12
}

View File

@@ -27,7 +27,10 @@ input {
general { general {
gaps_in = 5 gaps_in = 5
gaps_out = 5 gaps_out = 5
border_size = 2 border_size = 0 # off in niri
col.active_border = rgba(707070ff)
col.inactive_border = rgba(d0d0d0ff)
layout = dwindle layout = dwindle
} }
@@ -39,7 +42,7 @@ decoration {
rounding = 12 rounding = 12
active_opacity = 1.0 active_opacity = 1.0
inactive_opacity = 1.0 inactive_opacity = 0.9
shadow { shadow {
enabled = true enabled = true
@@ -81,37 +84,196 @@ master {
misc { misc {
disable_hyprland_logo = true disable_hyprland_logo = true
disable_splash_rendering = true disable_splash_rendering = true
vrr = 1
} }
# ================== # ==================
# WINDOW RULES # WINDOW RULES
# ================== # ==================
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$ windowrulev2 = tile, class:^(org\.wezfurlong\.wezterm)$
windowrule = rounding 12, match:class ^(org\.gnome\.) windowrulev2 = rounding 12, class:^(org\.gnome\.)
windowrulev2 = noborder, class:^(org\.gnome\.)
windowrule = tile on, match:class ^(gnome-control-center)$ windowrulev2 = tile, class:^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$ windowrulev2 = tile, class:^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$ windowrulev2 = tile, class:^(nm-connection-editor)$
windowrule = float on, match:class ^(gnome-calculator)$ windowrulev2 = float, class:^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$ windowrulev2 = float, class:^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$ windowrulev2 = float, class:^(blueman-manager)$
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$ windowrulev2 = float, class:^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(steam)$ windowrulev2 = float, class:^(steam)$
windowrule = float on, match:class ^(xdg-desktop-portal)$ windowrulev2 = float, class:^(xdg-desktop-portal)$
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$ windowrulev2 = noborder, class:^(org\.wezfurlong\.wezterm)$
windowrule = float on, match:class ^(zoom)$ 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)$
# DMS windows floating by default # DMS windows floating by default
# ! Hyprland doesn't size these windows correctly so disabling by default here windowrulev2 = float, class:^(org.quickshell)$
# windowrule = float on, match:class ^(org.quickshell)$ windowrulev2 = opacity 0.9 0.9, floating:0, focus:0
layerrule = no_anim on, match:namespace ^(quickshell)$ layerrule = noanim, ^(quickshell)$
source = ./dms/colors.conf # ==================
source = ./dms/outputs.conf # KEYBINDINGS
source = ./dms/layout.conf # ==================
source = ./dms/cursor.conf $mod = SUPER
source = ./dms/binds.conf
# === 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 focusOrToggle
bind = $mod, comma, exec, dms ipc call settings focusOrToggle
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 focusOrToggle
# === 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 = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = $mod SHIFT, P, dpms, toggle

View File

@@ -1,3 +1,8 @@
// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
binds { binds {
// === System & Overview === // === System & Overview ===
Mod+D repeat=false { toggle-overview; } Mod+D repeat=false { toggle-overview; }
@@ -15,8 +20,6 @@ binds {
Mod+M hotkey-overlay-title="Task Manager" { Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "focusOrToggle"; spawn "dms" "ipc" "call" "processlist" "focusOrToggle";
} }
Super+X hotkey-overlay-title="Power Menu: Toggle" { spawn "dms" "ipc" "call" "powermenu" "toggle"; }
Mod+Comma hotkey-overlay-title="Settings" { Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "focusOrToggle"; spawn "dms" "ipc" "call" "settings" "focusOrToggle";
} }
@@ -48,18 +51,6 @@ binds {
XF86AudioMicMute allow-when-locked=true { XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute"; spawn "dms" "ipc" "call" "audio" "micmute";
} }
XF86AudioPause allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPlay allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPrev allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "previous";
}
XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next";
}
// === Brightness Controls === // === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true { XF86MonBrightnessUp allow-when-locked=true {
@@ -133,11 +124,6 @@ binds {
Mod+Ctrl+U { move-column-to-workspace-down; } Mod+Ctrl+U { move-column-to-workspace-down; }
Mod+Ctrl+I { move-column-to-workspace-up; } Mod+Ctrl+I { move-column-to-workspace-up; }
// === Workspace Management ===
Ctrl+Shift+R hotkey-overlay-title="Rename Workspace" {
spawn "dms" "ipc" "call" "workspace-rename" "open";
}
// === Move Workspaces === // === Move Workspaces ===
Mod+Shift+Page_Down { move-workspace-down; } Mod+Shift+Page_Down { move-workspace-down; }
Mod+Shift+Page_Up { move-workspace-up; } Mod+Shift+Page_Up { move-workspace-up; }

View File

@@ -1,19 +1,21 @@
// ! Auto-generated file. Do not edit directly. // ! DO NOT EDIT !
// Remove `include "dms/colors.kdl"` from your config to override. // ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
layout { layout {
background-color "transparent" background-color "transparent"
focus-ring { focus-ring {
active-color "#d0bcff" active-color "#9dcbfb"
inactive-color "#948f99" inactive-color "#8c9199"
urgent-color "#f2b8b5" urgent-color "#ffb4ab"
} }
border { border {
active-color "#d0bcff" active-color "#9dcbfb"
inactive-color "#948f99" inactive-color "#8c9199"
urgent-color "#f2b8b5" urgent-color "#ffb4ab"
} }
shadow { shadow {
@@ -21,19 +23,19 @@ layout {
} }
tab-indicator { tab-indicator {
active-color "#d0bcff" active-color "#9dcbfb"
inactive-color "#948f99" inactive-color "#8c9199"
urgent-color "#f2b8b5" urgent-color "#ffb4ab"
} }
insert-hint { insert-hint {
color "#d0bcff80" color "#9dcbfb80"
} }
} }
recent-windows { recent-windows {
highlight { highlight {
active-color "#4f378b" active-color "#124a73"
urgent-color "#f2b8b5" urgent-color "#ffb4ab"
} }
} }

View File

@@ -240,6 +240,10 @@ window-rule {
match app-id="kitty" match app-id="kitty"
draw-border-with-background false draw-border-with-background false
} }
window-rule {
match is-active=false
opacity 0.9
}
window-rule { window-rule {
match app-id=r#"firefox$"# title="^Picture-in-Picture$" match app-id=r#"firefox$"# title="^Picture-in-Picture$"
match app-id="zoom" match app-id="zoom"
@@ -269,5 +273,3 @@ include "dms/colors.kdl"
include "dms/layout.kdl" include "dms/layout.kdl"
include "dms/alttab.kdl" include "dms/alttab.kdl"
include "dms/binds.kdl" include "dms/binds.kdl"
include "dms/outputs.kdl"
include "dms/cursor.kdl"

View File

@@ -4,12 +4,3 @@ import _ "embed"
//go:embed embedded/hyprland.conf //go:embed embedded/hyprland.conf
var HyprlandConfig string var HyprlandConfig string
//go:embed embedded/hypr-colors.conf
var HyprColorsConfig string
//go:embed embedded/hypr-layout.conf
var HyprLayoutConfig string
//go:embed embedded/hypr-binds.conf
var HyprBindsConfig string

View File

@@ -199,6 +199,31 @@ func labToHex(L, a, b float64) string {
return fmt.Sprintf("#%02x%02x%02x", r, g, b2) 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 { func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 {
Lf := getLstar(hexFg) Lf := getLstar(hexFg)
Lb := getLstar(hexBg) Lb := getLstar(hexBg)
@@ -320,7 +345,7 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
} }
step := 0.5 step := 0.5
for range 120 { for i := 0; i < 120; i++ {
Lf = math.Max(0, math.Min(100, Lf+dir*step)) Lf = math.Max(0, math.Min(100, Lf+dir*step))
cand := labToHex(Lf, af, bf) cand := labToHex(Lf, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc { if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
@@ -331,59 +356,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
return hexColor return hexColor
} }
// Bidirectional contrast - tries both lighter and darker, picks closest to original
func EnsureContrastDPSBidirectional(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}
origL, af, bf := cf.Lab()
var darkerResult, lighterResult string
darkerL, lighterL := origL, origL
darkerFound, lighterFound := false, false
step := 0.5
for i := range 120 {
if !darkerFound {
darkerL = math.Max(0, origL-float64(i)*step)
cand := labToHex(darkerL, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
darkerResult = cand
darkerFound = true
}
}
if !lighterFound {
lighterL = math.Min(100, origL+float64(i)*step)
cand := labToHex(lighterL, af, bf)
if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc {
lighterResult = cand
lighterFound = true
}
}
if darkerFound && lighterFound {
break
}
}
if darkerFound && lighterFound {
if math.Abs(darkerL-origL) <= math.Abs(lighterL-origL) {
return darkerResult
}
return lighterResult
}
if darkerFound {
return darkerResult
}
if lighterFound {
return lighterResult
}
return hexColor
}
type PaletteOptions struct { type PaletteOptions struct {
IsLight bool IsLight bool
Background string Background string
@@ -397,29 +369,6 @@ func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOpti
return EnsureContrast(hexColor, hexBg, target, opts.IsLight) return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
} }
func ensureContrastBidirectional(hexColor, hexBg string, target float64, opts PaletteOptions) string {
if opts.UseDPS {
return EnsureContrastDPSBidirectional(hexColor, hexBg, target, opts.IsLight)
}
return EnsureContrast(hexColor, hexBg, target, opts.IsLight)
}
func blendHue(base, target, factor float64) float64 {
diff := target - base
if diff > 0.5 {
diff -= 1.0
} else if diff < -0.5 {
diff += 1.0
}
result := base + diff*factor
if result < 0 {
result += 1.0
} else if result >= 1.0 {
result -= 1.0
}
return result
}
func DeriveContainer(primary string, isLight bool) string { func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary) rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb) hsv := RGBToHSV(rgb)
@@ -440,9 +389,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
rgb := HexToRGB(baseColor) rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb) hsv := RGBToHSV(rgb)
pr := HexToRGB(primaryColor)
ph := RGBToHSV(pr)
var palette Palette var palette Palette
var normalTextTarget, secondaryTarget float64 var normalTextTarget, secondaryTarget float64
@@ -464,136 +410,115 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
} }
palette.Color0 = NewColorInfo(bgColor) palette.Color0 = NewColorInfo(bgColor)
baseSat := math.Max(ph.S, 0.5) hueShift := (hsv.H - 0.6) * 0.12
baseVal := math.Max(ph.V, 0.5) satBoost := 1.15
redH := blendHue(0.0, ph.H, 0.12) redH := math.Mod(0.0+hueShift+1.0, 1.0)
greenH := blendHue(0.33, ph.H, 0.10) var redColor string
yellowH := blendHue(0.14, ph.H, 0.04) if opts.IsLight {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.80*satBoost, 1.0), V: 0.55}))
palette.Color1 = NewColorInfo(ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
} else {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.65*satBoost, 1.0), V: 0.80}))
palette.Color1 = NewColorInfo(ensureContrastAuto(redColor, bgColor, normalTextTarget, opts))
}
accentTarget := secondaryTarget * 0.7 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.Color2 = NewColorInfo(ensureContrastAuto(greenColor, bgColor, normalTextTarget, opts))
} else {
greenColor = RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.42*satBoost, 1.0), V: 0.84}))
palette.Color2 = NewColorInfo(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.Color3 = NewColorInfo(ensureContrastAuto(yellowColor, bgColor, normalTextTarget, opts))
} else {
yellowColor = RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.38*satBoost, 1.0), V: 0.86}))
palette.Color3 = NewColorInfo(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.Color4 = NewColorInfo(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.Color4 = NewColorInfo(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.Color5 = NewColorInfo(ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
} else {
magColor = RGBToHex(HSVToRGB(HSV{H: hh.H, S: hh.S * 0.8, V: hh.V * 0.75}))
palette.Color5 = NewColorInfo(ensureContrastAuto(magColor, bgColor, normalTextTarget, opts))
}
cyanH := hsv.H + 0.08
if cyanH > 1.0 {
cyanH -= 1.0
}
palette.Color6 = NewColorInfo(ensureContrastAuto(primaryColor, bgColor, normalTextTarget, opts))
if opts.IsLight { if opts.IsLight {
redS := math.Min(baseSat*1.2, 1.0) palette.Color7 = NewColorInfo("#1a1a1a")
redV := baseVal * 0.95 palette.Color8 = NewColorInfo("#2e2e2e")
palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts))
greenS := math.Min(baseSat*1.3, 1.0)
greenV := baseVal * 0.75
palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts))
yellowS := math.Min(baseSat*1.5, 1.0)
yellowV := math.Min(baseVal*1.2, 1.0)
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
blueS := math.Min(ph.S*1.05, 1.0)
blueV := math.Min(ph.V*1.05, 1.0)
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (light container in light mode)
container5 := DeriveContainer(primaryColor, true)
palette.Color5 = NewColorInfo(container5)
palette.Color6 = NewColorInfo(primaryColor)
gray7S := baseSat * 0.08
gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.05
gray8V := baseVal * 0.85
dimTarget := secondaryTarget * 0.5
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
brightRedS := math.Min(baseSat*1.0, 1.0)
brightRedV := math.Min(baseVal*1.2, 1.0)
palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts))
brightGreenS := math.Min(baseSat*1.1, 1.0)
brightGreenV := math.Min(baseVal*1.1, 1.0)
palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts))
brightYellowS := math.Min(baseSat*1.4, 1.0)
brightYellowV := math.Min(baseVal*1.3, 1.0)
palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts))
brightBlueS := math.Min(ph.S*1.1, 1.0)
brightBlueV := math.Min(ph.V*1.15, 1.0)
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
lightContainer := DeriveContainer(primaryColor, true)
palette.Color13 = NewColorInfo(lightContainer)
brightCyanS := ph.S * 0.5
brightCyanV := math.Min(ph.V*1.3, 1.0)
palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightCyanS, V: brightCyanV})))
white15S := baseSat * 0.04
white15V := math.Min(baseVal*1.5, 1.0)
palette.Color15 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})))
} else { } else {
redS := math.Min(baseSat*1.1, 1.0) palette.Color7 = NewColorInfo("#abb2bf")
redV := math.Min(baseVal*1.15, 1.0) palette.Color8 = NewColorInfo("#5c6370")
palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts)) }
greenS := math.Min(baseSat*1.0, 1.0) if opts.IsLight {
greenV := math.Min(baseVal*1.0, 1.0) brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.70*satBoost, 1.0), V: 0.65}))
palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts)) palette.Color9 = NewColorInfo(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.Color10 = NewColorInfo(ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.68*satBoost, 1.0), V: 0.60}))
palette.Color11 = NewColorInfo(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.Color12 = NewColorInfo(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.Color13 = NewColorInfo(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.Color14 = NewColorInfo(ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
} else {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.50*satBoost, 1.0), V: 0.88}))
palette.Color9 = NewColorInfo(ensureContrastAuto(brightRed, bgColor, secondaryTarget, opts))
brightGreen := RGBToHex(HSVToRGB(HSV{H: greenH, S: math.Min(0.35*satBoost, 1.0), V: 0.88}))
palette.Color10 = NewColorInfo(ensureContrastAuto(brightGreen, bgColor, secondaryTarget, opts))
brightYellow := RGBToHex(HSVToRGB(HSV{H: yellowH, S: math.Min(0.30*satBoost, 1.0), V: 0.91}))
palette.Color11 = NewColorInfo(ensureContrastAuto(brightYellow, bgColor, secondaryTarget, opts))
brightBlue := retoneToL(primaryColor, 85.0)
palette.Color12 = NewColorInfo(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.Color13 = NewColorInfo(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.Color14 = NewColorInfo(ensureContrastAuto(brightCyan, bgColor, secondaryTarget, opts))
}
yellowS := math.Min(baseSat*1.1, 1.0) if opts.IsLight {
yellowV := math.Min(baseVal*1.25, 1.0) palette.Color15 = NewColorInfo("#1a1a1a")
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts)) } else {
palette.Color15 = NewColorInfo("#ffffff")
// Slightly more saturated variant of primary
blueS := math.Min(ph.S*1.2, 1.0)
blueV := ph.V * 0.95
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (dark container in dark mode)
darkContainer := DeriveContainer(primaryColor, false)
palette.Color5 = NewColorInfo(darkContainer)
palette.Color6 = NewColorInfo(primaryColor)
gray7S := baseSat * 0.12
gray7V := math.Min(baseVal*1.05, 1.0)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.15
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
brightRedS := math.Min(baseSat*0.75, 1.0)
brightRedV := math.Min(baseVal*1.35, 1.0)
palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts))
brightGreenS := math.Min(baseSat*0.7, 1.0)
brightGreenV := math.Min(baseVal*1.2, 1.0)
palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts))
brightYellowS := math.Min(baseSat*0.7, 1.0)
brightYellowV := math.Min(baseVal*1.5, 1.0)
palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts))
// Create a gradient of primary variants: Color12 -> Color13 -> Color14 -> Color15 (near white)
// Color12: Start of the lighter gradient - slightly desaturated
brightBlueS := ph.S * 0.85
brightBlueV := math.Min(ph.V*1.1, 1.0)
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
// Medium-high saturation pastel primary
color13S := ph.S * 0.7
color13V := math.Min(ph.V*1.3, 1.0)
palette.Color13 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})))
// Lower saturation, lighter variant
color14S := ph.S * 0.45
color14V := math.Min(ph.V*1.4, 1.0)
palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color14S, V: color14V})))
white15S := baseSat * 0.05
white15V := math.Min(baseVal*1.45, 1.0)
palette.Color15 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})), bgColor, normalTextTarget, opts))
} }
return palette return palette

View File

@@ -366,19 +366,10 @@ func TestGeneratePalette(t *testing.T) {
t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex) t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex)
} }
// Color15 is now derived from primary, so just verify it's a valid color if tt.opts.IsLight && result.Color15.Hex != "#1a1a1a" {
// and has appropriate luminance for the mode (now theme-tinted, not pure white/black) t.Errorf("Light mode foreground = %s, expected #1a1a1a", result.Color15.Hex)
color15Lum := Luminance(result.Color15.Hex) } else if !tt.opts.IsLight && result.Color15.Hex != "#ffffff" {
if tt.opts.IsLight { t.Errorf("Dark mode foreground = %s, expected #ffffff", result.Color15.Hex)
// Light mode: Color15 should still be relatively light
if color15Lum < 0.5 {
t.Errorf("Light mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum)
}
} else {
// Dark mode: Color15 should be light (but may have theme tint, so lower threshold)
if color15Lum < 0.5 {
t.Errorf("Dark mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum)
}
} }
}) })
} }
@@ -588,10 +579,6 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
bgColor := result.Color0.Hex bgColor := result.Color0.Hex
for i := 1; i < 8; i++ { for i := 1; i < 8; i++ {
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
if i == 5 || i == 6 {
continue
}
lc := DeltaPhiStarContrast(colors[i].Hex, bgColor, tt.opts.IsLight) lc := DeltaPhiStarContrast(colors[i].Hex, bgColor, tt.opts.IsLight)
minLc := 30.0 minLc := 30.0
if lc < minLc && lc > 0 { if lc < minLc && lc > 0 {
@@ -671,7 +658,7 @@ func TestContrastAlgorithmComparison(t *testing.T) {
} }
differentCount := 0 differentCount := 0
for i := range 16 { for i := 0; i < 16; i++ {
if wcagColors[i].Hex != dpsColors[i].Hex { if wcagColors[i].Hex != dpsColors[i].Hex {
differentCount++ differentCount++
} }

View File

@@ -7,7 +7,6 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
@@ -41,9 +40,6 @@ func init() {
Register("artix", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { Register("artix", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan) return NewArchDistribution(config, logChan)
}) })
Register("XeroLinux", "#888fe2", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
} }
type ArchDistribution struct { type ArchDistribution struct {
@@ -518,9 +514,12 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
dmsShell = append(dmsShell, pkg) dmsShell = append(dmsShell, pkg)
} else { } else {
isDep := false isDep := false
if slices.Contains(dmsDepencies, pkg) { for _, dep := range dmsDepencies {
deps = append(deps, pkg) if pkg == dep {
isDep = true deps = append(deps, pkg)
isDep = true
break
}
} }
if !isDep { if !isDep {
others = append(others, pkg) others = append(others, pkg)
@@ -546,7 +545,7 @@ func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sud
a.log(fmt.Sprintf("Warning: failed to clean existing cache for %s: %v", pkg, err)) a.log(fmt.Sprintf("Warning: failed to clean existing cache for %s: %v", pkg, err))
} }
if err := os.MkdirAll(buildDir, 0o755); err != nil { if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err) return fmt.Errorf("failed to create build directory: %w", err)
} }
defer func() { defer func() {

View File

@@ -534,7 +534,7 @@ func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error
} }
envDir := filepath.Join(homeDir, ".config", "environment.d") envDir := filepath.Join(homeDir, ".config", "environment.d")
if err := os.MkdirAll(envDir, 0o755); err != nil { if err := os.MkdirAll(envDir, 0755); err != nil {
return fmt.Errorf("failed to create environment.d directory: %w", err) return fmt.Errorf("failed to create environment.d directory: %w", err)
} }
@@ -555,7 +555,7 @@ TERMINAL=%s
`, terminalCmd) `, terminalCmd)
envFile := filepath.Join(envDir, "90-dms.conf") envFile := filepath.Join(envDir, "90-dms.conf")
if err := os.WriteFile(envFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(envFile, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write environment config: %w", err) return fmt.Errorf("failed to write environment config: %w", err)
} }
@@ -594,7 +594,7 @@ func (b *BaseDistribution) WriteHyprlandSessionTarget() error {
} }
targetDir := filepath.Join(homeDir, ".config", "systemd", "user") targetDir := filepath.Join(homeDir, ".config", "systemd", "user")
if err := os.MkdirAll(targetDir, 0o755); err != nil { if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create systemd user directory: %w", err) return fmt.Errorf("failed to create systemd user directory: %w", err)
} }
@@ -605,7 +605,7 @@ Requires=graphical-session.target
After=graphical-session.target After=graphical-session.target
` `
if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil { if err := os.WriteFile(targetPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write hyprland-session.target: %w", err) return fmt.Errorf("failed to write hyprland-session.target: %w", err)
} }

View File

@@ -43,7 +43,7 @@ func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755) os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME") originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome) defer os.Setenv("HOME", originalHome)
@@ -55,7 +55,7 @@ func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run() exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run()
testFile := filepath.Join(dmsPath, "test.txt") testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0o644) os.WriteFile(testFile, []byte("test"), 0644)
exec.Command("git", "-C", dmsPath, "add", ".").Run() exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run() exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run()
@@ -87,7 +87,7 @@ func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755) os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME") originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome) defer os.Setenv("HOME", originalHome)
@@ -99,7 +99,7 @@ func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
exec.Command("git", "-C", dmsPath, "remote", "add", "origin", "https://github.com/AvengeMedia/DankMaterialShell.git").Run() exec.Command("git", "-C", dmsPath, "remote", "add", "origin", "https://github.com/AvengeMedia/DankMaterialShell.git").Run()
testFile := filepath.Join(dmsPath, "test.txt") testFile := filepath.Join(dmsPath, "test.txt")
os.WriteFile(testFile, []byte("test"), 0o644) os.WriteFile(testFile, []byte("test"), 0644)
exec.Command("git", "-C", dmsPath, "add", ".").Run() exec.Command("git", "-C", dmsPath, "add", ".").Run()
exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").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, "tag", "v0.0.1").Run()
@@ -125,7 +125,7 @@ func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
func TestBaseDistribution_detectDMS_DirectoryWithoutGit(t *testing.T) { func TestBaseDistribution_detectDMS_DirectoryWithoutGit(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755) os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME") originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome) defer os.Setenv("HOME", originalHome)

View File

@@ -153,7 +153,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
} }
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"} return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
} }
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {

View File

@@ -18,8 +18,8 @@ type ManualPackageInstaller struct {
// parseLatestTagFromGitOutput parses git ls-remote output and returns the latest tag // parseLatestTagFromGitOutput parses git ls-remote output and returns the latest tag
func (m *ManualPackageInstaller) parseLatestTagFromGitOutput(output string) string { func (m *ManualPackageInstaller) parseLatestTagFromGitOutput(output string) string {
lines := strings.SplitSeq(output, "\n") lines := strings.Split(output, "\n")
for line := range lines { for _, line := range lines {
if strings.Contains(line, "refs/tags/") && !strings.Contains(line, "^{}") { if strings.Contains(line, "refs/tags/") && !strings.Contains(line, "^{}") {
parts := strings.Split(line, "refs/tags/") parts := strings.Split(line, "refs/tags/")
if len(parts) > 1 { if len(parts) > 1 {
@@ -103,12 +103,12 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
tmpDir := filepath.Join(cacheDir, "dgop-build") tmpDir := filepath.Join(cacheDir, "dgop-build")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -160,10 +160,10 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
homeDir, _ := os.UserHomeDir() homeDir, _ := os.UserHomeDir()
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build") buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build")
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp") tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp")
if err := os.MkdirAll(buildDir, 0o755); err != nil { if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err) return fmt.Errorf("failed to create build directory: %w", err)
} }
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
defer func() { defer func() {
@@ -237,12 +237,12 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
tmpDir := filepath.Join(cacheDir, "quickshell-build") tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -273,7 +273,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
} }
buildDir := tmpDir + "/build" buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0o755); err != nil { if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err) return fmt.Errorf("failed to create build directory: %w", err)
} }
@@ -343,12 +343,12 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
tmpDir := filepath.Join(cacheDir, "hyprland-build") tmpDir := filepath.Join(cacheDir, "hyprland-build")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -406,12 +406,12 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
tmpDir := filepath.Join(cacheDir, "ghostty-build") tmpDir := filepath.Join(cacheDir, "ghostty-build")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -528,7 +528,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
} }
configDir := filepath.Dir(dmsPath) configDir := filepath.Dir(dmsPath)
if err := os.MkdirAll(configDir, 0o755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create quickshell config directory: %w", err) return fmt.Errorf("failed to create quickshell config directory: %w", err)
} }

View File

@@ -108,6 +108,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
packages := map[string]PackageMapping{ packages := map[string]PackageMapping{
// Standard zypper packages // Standard zypper packages
"git": {Name: "git", Repository: RepoTypeSystem}, "git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem}, "kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, "alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
@@ -116,7 +117,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
// DMS packages from OBS // DMS packages from OBS
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]), "dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
"quickshell": o.getQuickshellMapping(variants["quickshell"]), "quickshell": o.getQuickshellMapping(variants["quickshell"]),
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
} }
@@ -540,12 +540,12 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
} }
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil { if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
tmpDir := filepath.Join(cacheDir, "quickshell-build") tmpDir := filepath.Join(cacheDir, "quickshell-build")
if err := os.MkdirAll(tmpDir, 0o755); err != nil { if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err) return fmt.Errorf("failed to create temp directory: %w", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -576,7 +576,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
} }
buildDir := tmpDir + "/build" buildDir := tmpDir + "/build"
if err := os.MkdirAll(buildDir, 0o755); err != nil { if err := os.MkdirAll(buildDir, 0755); err != nil {
return fmt.Errorf("failed to create build directory: %w", err) return fmt.Errorf("failed to create build directory: %w", err)
} }

450
core/internal/dms/app.go Normal file
View File

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

View File

@@ -0,0 +1,267 @@
//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()
var dependencies []DependencyInfo
var hyprlandInstalled, niriInstalled bool
if detector != nil {
dependencies = detector.GetInstalledComponents()
hyprlandInstalled, niriInstalled, _ = detector.GetWindowManagerStatus()
}
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 pluginUpdatedMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsError = ""
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

View File

@@ -0,0 +1,143 @@
package dms
import (
"context"
"os"
"os/exec"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/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{}
}
var components []DependencyInfo
for _, dep := range dependencies {
components = append(components, DependencyInfo{
Name: dep.Name,
Status: dep.Status,
Description: dep.Description,
Required: dep.Required,
})
}
return components
}
type DependencyInfo struct {
Name string
Status deps.DependencyStatus
Description string
Required bool
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,377 @@
package dms
import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/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)
}
case "p":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, updatePlugin(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
}
type pluginUpdatedMsg 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}
}
}
func updatePlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUpdatedMsg{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.Update(p); err != nil {
return pluginUpdatedMsg{pluginName: plugin.Name, err: err}
}
return pluginUpdatedMsg{pluginName: plugin.Name}
}
}

View File

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

View File

@@ -0,0 +1,152 @@
package dms
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/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")
if len(m.dependencies) == 0 {
b.WriteString(normalStyle.Render("\n Component detection not supported on this platform."))
}
for _, dep := range m.dependencies {
status := "✗"
if dep.Status == 1 {
status = "✓"
}
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s %s", status, dep.Name)))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Esc: Back to main menu"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderBanner() string {
theme := tui.TerminalTheme()
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true).
MarginBottom(1)
return titleStyle.Render(logo)
}

View File

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

View File

@@ -235,7 +235,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
for _, dir := range parentDirs { for _, dir := range parentDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) { if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0o755); err != nil { if err := os.MkdirAll(dir.path, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.desc, err)) logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.desc, err))
continue continue
} }
@@ -295,7 +295,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
for _, dir := range configDirs { for _, dir := range configDirs {
if _, err := os.Stat(dir.path); os.IsNotExist(err) { if _, err := os.Stat(dir.path); os.IsNotExist(err) {
if err := os.MkdirAll(dir.path, 0o755); err != nil { if err := os.MkdirAll(dir.path, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.path, err)) logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.path, err))
continue continue
} }
@@ -355,14 +355,14 @@ func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) e
for _, link := range symlinks { for _, link := range symlinks {
sourceDir := filepath.Dir(link.source) sourceDir := filepath.Dir(link.source)
if _, err := os.Stat(sourceDir); os.IsNotExist(err) { if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
if err := os.MkdirAll(sourceDir, 0o755); err != nil { if err := os.MkdirAll(sourceDir, 0755); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create directory %s: %v", sourceDir, err)) logFunc(fmt.Sprintf("⚠ Warning: Could not create directory %s: %v", sourceDir, err))
continue continue
} }
} }
if _, err := os.Stat(link.source); os.IsNotExist(err) { if _, err := os.Stat(link.source); os.IsNotExist(err) {
if err := os.WriteFile(link.source, []byte("{}"), 0o644); err != nil { if err := os.WriteFile(link.source, []byte("{}"), 0644); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", link.source, err)) logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", link.source, err))
continue continue
} }
@@ -455,7 +455,7 @@ user = "greeter"
newConfig := strings.Join(finalLines, "\n") newConfig := strings.Join(finalLines, "\n")
tmpFile := "/tmp/greetd-config.toml" tmpFile := "/tmp/greetd-config.toml"
if err := os.WriteFile(tmpFile, []byte(newConfig), 0o644); err != nil { if err := os.WriteFile(tmpFile, []byte(newConfig), 0644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err) return fmt.Errorf("failed to write temp config: %w", err)
} }

View File

@@ -23,7 +23,7 @@ func DefaultDiscoveryConfig() *DiscoveryConfig {
configDirs := os.Getenv("XDG_CONFIG_DIRS") configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs != "" { if configDirs != "" {
for dir := range strings.SplitSeq(configDirs, ":") { for _, dir := range strings.Split(configDirs, ":") {
if dir != "" { if dir != "" {
searchPaths = append(searchPaths, filepath.Join(dir, "DankMaterialShell", "cheatsheets")) searchPaths = append(searchPaths, filepath.Join(dir, "DankMaterialShell", "cheatsheets"))
} }

View File

@@ -79,16 +79,16 @@ func TestFindJSONFiles(t *testing.T) {
txtFile := filepath.Join(tmpDir, "readme.txt") txtFile := filepath.Join(tmpDir, "readme.txt")
subdir := filepath.Join(tmpDir, "subdir") subdir := filepath.Join(tmpDir, "subdir")
if err := os.WriteFile(file1, []byte("{}"), 0o644); err != nil { if err := os.WriteFile(file1, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file1: %v", err) t.Fatalf("Failed to create file1: %v", err)
} }
if err := os.WriteFile(file2, []byte("{}"), 0o644); err != nil { if err := os.WriteFile(file2, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file2: %v", err) t.Fatalf("Failed to create file2: %v", err)
} }
if err := os.WriteFile(txtFile, []byte("text"), 0o644); err != nil { if err := os.WriteFile(txtFile, []byte("text"), 0644); err != nil {
t.Fatalf("Failed to create txt file: %v", err) t.Fatalf("Failed to create txt file: %v", err)
} }
if err := os.MkdirAll(subdir, 0o755); err != nil { if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err) t.Fatalf("Failed to create subdir: %v", err)
} }
@@ -143,10 +143,10 @@ func TestFindJSONFilesMultiplePaths(t *testing.T) {
file1 := filepath.Join(tmpDir1, "app1.json") file1 := filepath.Join(tmpDir1, "app1.json")
file2 := filepath.Join(tmpDir2, "app2.json") file2 := filepath.Join(tmpDir2, "app2.json")
if err := os.WriteFile(file1, []byte("{}"), 0o644); err != nil { if err := os.WriteFile(file1, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file1: %v", err) t.Fatalf("Failed to create file1: %v", err)
} }
if err := os.WriteFile(file2, []byte("{}"), 0o644); err != nil { if err := os.WriteFile(file2, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create file2: %v", err) t.Fatalf("Failed to create file2: %v", err)
} }
@@ -174,7 +174,7 @@ func TestAutoDiscoverProviders(t *testing.T) {
}` }`
file := filepath.Join(tmpDir, "testapp.json") file := filepath.Join(tmpDir, "testapp.json")
if err := os.WriteFile(file, []byte(jsonContent), 0o644); err != nil { if err := os.WriteFile(file, []byte(jsonContent), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err) t.Fatalf("Failed to create test file: %v", err)
} }
@@ -226,7 +226,7 @@ func TestAutoDiscoverProvidersNoFactory(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
file := filepath.Join(tmpDir, "test.json") file := filepath.Join(tmpDir, "test.json")
if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil { if err := os.WriteFile(file, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err) t.Fatalf("Failed to create test file: %v", err)
} }

View File

@@ -2,93 +2,45 @@ package providers
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"sort"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
type HyprlandProvider struct { type HyprlandProvider struct {
configPath string configPath string
dmsBindsIncluded bool
parsed bool
} }
func NewHyprlandProvider(configPath string) *HyprlandProvider { func NewHyprlandProvider(configPath string) *HyprlandProvider {
if configPath == "" { if configPath == "" {
configPath = defaultHyprlandConfigDir() configPath = "$HOME/.config/hypr"
} }
return &HyprlandProvider{ return &HyprlandProvider{
configPath: configPath, configPath: configPath,
} }
} }
func defaultHyprlandConfigDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(configDir, "hypr")
}
func (h *HyprlandProvider) Name() string { func (h *HyprlandProvider) Name() string {
return "hyprland" return "hyprland"
} }
func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
result, err := ParseHyprlandKeysWithDMS(h.configPath) section, err := ParseHyprlandKeys(h.configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse hyprland config: %w", err) return nil, fmt.Errorf("failed to parse hyprland config: %w", err)
} }
h.dmsBindsIncluded = result.DMSBindsIncluded
h.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind) categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs) h.convertSection(section, "", categorizedBinds)
sheet := &keybinds.CheatSheet{ return &keybinds.CheatSheet{
Title: "Hyprland Keybinds", Title: "Hyprland Keybinds",
Provider: h.Name(), Provider: h.Name(),
Binds: categorizedBinds, Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded, }, nil
}
if result.DMSStatus != nil {
sheet.DMSStatus = &keybinds.DMSBindsStatus{
Exists: result.DMSStatus.Exists,
Included: result.DMSStatus.Included,
IncludePosition: result.DMSStatus.IncludePosition,
TotalIncludes: result.DMSStatus.TotalIncludes,
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
}
}
return sheet, nil
} }
func (h *HyprlandProvider) HasDMSBindsIncluded() bool { func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
if h.parsed {
return h.dmsBindsIncluded
}
result, err := ParseHyprlandKeysWithDMS(h.configPath)
if err != nil {
return false
}
h.dmsBindsIncluded = result.DMSBindsIncluded
h.parsed = true
return h.dmsBindsIncluded
}
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
currentSubcat := subcategory currentSubcat := subcategory
if section.Name != "" { if section.Name != "" {
currentSubcat = section.Name currentSubcat = section.Name
@@ -96,12 +48,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
for _, kb := range section.Keybinds { for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher) category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat, conflicts) bind := h.convertKeybind(&kb, currentSubcat)
categorizedBinds[category] = append(categorizedBinds[category], bind) categorizedBinds[category] = append(categorizedBinds[category], bind)
} }
for _, child := range section.Children { for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts) h.convertSection(&child, currentSubcat, categorizedBinds)
} }
} }
@@ -133,8 +85,8 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
} }
} }
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind { func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string) keybinds.Keybind {
keyStr := h.formatKey(kb) key := h.formatKey(kb)
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
desc := kb.Comment desc := kb.Comment
@@ -142,33 +94,12 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
desc = rawAction desc = rawAction
} }
source := "config" return keybinds.Keybind{
if strings.Contains(kb.Source, "dms/binds.conf") { Key: key,
source = "dms"
}
bind := keybinds.Keybind{
Key: keyStr,
Description: desc, Description: desc,
Action: rawAction, Action: rawAction,
Subcategory: subcategory, Subcategory: subcategory,
Source: source,
Flags: kb.Flags,
} }
if source == "dms" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Comment,
Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params),
Source: "config",
}
}
}
return bind
} }
func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string {
@@ -184,314 +115,3 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
parts = append(parts, kb.Key) parts = append(parts, kb.Key)
return strings.Join(parts, "+") return strings.Join(parts, "+")
} }
func (h *HyprlandProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
return filepath.Join(h.configPath, "dms", "binds.conf")
}
return filepath.Join(expanded, "dms", "binds.conf")
}
func (h *HyprlandProvider) validateAction(action string) error {
action = strings.TrimSpace(action)
switch {
case action == "":
return fmt.Errorf("action cannot be empty")
case action == "exec" || action == "exec ":
return fmt.Errorf("exec dispatcher requires arguments")
case strings.HasPrefix(action, "exec "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "exec "))
if rest == "" {
return fmt.Errorf("exec dispatcher requires arguments")
}
}
return nil
}
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.validateAction(action); err != nil {
return err
}
overridePath := h.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
existingBinds, err := h.loadOverrideBinds()
if err != nil {
existingBinds = make(map[string]*hyprlandOverrideBind)
}
// Extract flags from options
var flags string
if options != nil {
if f, ok := options["flags"].(string); ok {
flags = f
}
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: key,
Action: action,
Description: description,
Flags: flags,
Options: options,
}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) RemoveBind(key string) error {
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds)
}
type hyprlandOverrideBind struct {
Key string
Action string
Description string
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
Options map[string]any
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
overridePath := h.GetOverridePath()
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
// Extract flags from bind type
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
// For bindd, format is: mods, key, description, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3
dispatcherIndex = 2
}
fields := strings.SplitN(bindContent, ",", minFields+2)
if len(fields) < minFields {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
var dispatcher, params string
if hasDescFlag {
if comment == "" {
comment = strings.TrimSpace(fields[descIndex])
}
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
keyStr := h.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := dispatcher
if params != "" {
action = dispatcher + " " + params
}
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
Flags: flags,
}
}
return binds, nil
}
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
if mods == "" {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
}
func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
return 0
case strings.Contains(action, "workspace"):
return 1
case strings.Contains(action, "window") || strings.Contains(action, "focus") ||
strings.Contains(action, "move") || strings.Contains(action, "swap") ||
strings.Contains(action, "resize"):
return 2
case strings.Contains(action, "monitor"):
return 3
case strings.HasPrefix(action, "exec"):
return 4
case action == "exit" || strings.Contains(action, "dpms"):
return 5
default:
return 6
}
}
func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error {
overridePath := h.GetOverridePath()
content := h.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0o644)
}
func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string {
if len(binds) == 0 {
return ""
}
bindList := make([]*hyprlandOverrideBind, 0, len(binds))
for _, bind := range binds {
bindList = append(bindList, bind)
}
sort.Slice(bindList, func(i, j int) bool {
pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action)
if pi != pj {
return pi < pj
}
return bindList[i].Key < bindList[j].Key
})
var sb strings.Builder
for _, bind := range bindList {
h.writeBindLine(&sb, bind)
}
return sb.String()
}
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
mods, key := h.parseKeyString(bind.Key)
dispatcher, params := h.parseAction(bind.Action)
// Write bind type with flags (e.g., "bind", "binde", "bindel")
sb.WriteString("bind")
if bind.Flags != "" {
sb.WriteString(bind.Flags)
}
sb.WriteString(" = ")
sb.WriteString(mods)
sb.WriteString(", ")
sb.WriteString(key)
sb.WriteString(", ")
// For bindd (description flag), include description before dispatcher
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
sb.WriteString(bind.Description)
sb.WriteString(", ")
}
sb.WriteString(dispatcher)
if params != "" {
sb.WriteString(", ")
sb.WriteString(params)
}
// Only add comment if not using bindd (which has inline description)
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
default:
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
}
}
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
dispatcher = parts[0]
default:
dispatcher = parts[0]
params = parts[1]
}
// Convert internal spawn format to Hyprland's exec
if dispatcher == "spawn" {
dispatcher = "exec"
}
return dispatcher, params
}

View File

@@ -23,8 +23,6 @@ type HyprlandKeyBinding struct {
Dispatcher string `json:"dispatcher"` Dispatcher string `json:"dispatcher"`
Params string `json:"params"` Params string `json:"params"`
Comment string `json:"comment"` Comment string `json:"comment"`
Source string `json:"source"`
Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
} }
type HyprlandSection struct { type HyprlandSection struct {
@@ -34,36 +32,14 @@ type HyprlandSection struct {
} }
type HyprlandParser struct { type HyprlandParser struct {
contentLines []string contentLines []string
readingLine int readingLine int
configDir string
currentSource string
dmsBindsExists bool
dmsBindsIncluded bool
includeCount int
dmsIncludePos int
bindsAfterDMS int
dmsBindKeys map[string]bool
configBindKeys map[string]bool
conflictingConfigs map[string]*HyprlandKeyBinding
bindMap map[string]*HyprlandKeyBinding
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
} }
func NewHyprlandParser(configDir string) *HyprlandParser { func NewHyprlandParser() *HyprlandParser {
return &HyprlandParser{ return &HyprlandParser{
contentLines: []string{}, contentLines: []string{},
readingLine: 0, readingLine: 0,
configDir: configDir,
dmsIncludePos: -1,
dmsBindKeys: make(map[string]bool),
configBindKeys: make(map[string]bool),
conflictingConfigs: make(map[string]*HyprlandKeyBinding),
bindMap: make(map[string]*HyprlandKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
} }
} }
@@ -219,7 +195,71 @@ func hyprlandAutogenerateComment(dispatcher, params string) string {
func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding { func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding {
line := p.contentLines[lineNumber] line := p.contentLines[lineNumber]
return p.parseBindLine(line) 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 = hyprlandAutogenerateComment(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 &HyprlandKeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
}
} }
func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection { func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection {
@@ -280,348 +320,9 @@ func (p *HyprlandParser) ParseKeys() *HyprlandSection {
} }
func ParseHyprlandKeys(path string) (*HyprlandSection, error) { func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
parser := NewHyprlandParser(path) parser := NewHyprlandParser()
if err := parser.ReadContent(path); err != nil { if err := parser.ReadContent(path); err != nil {
return nil, err return nil, err
} }
return parser.ParseKeys(), nil return parser.ParseKeys(), nil
} }
type HyprlandParseResult struct {
Section *HyprlandSection
DMSBindsIncluded bool
DMSStatus *HyprlandDMSStatus
ConflictingConfigs map[string]*HyprlandKeyBinding
}
type HyprlandDMSStatus struct {
Exists bool
Included bool
IncludePosition int
TotalIncludes int
BindsAfterDMS int
Effective bool
OverriddenBy int
StatusMessage string
}
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
status := &HyprlandDMSStatus{
Exists: p.dmsBindsExists,
Included: p.dmsBindsIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
}
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
status.StatusMessage = "Some DMS binds may be overridden by config binds"
default:
status.Effective = true
status.StatusMessage = "DMS binds are active"
}
return status
}
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (p *HyprlandParser) normalizeKey(key string) string {
return strings.ToLower(key)
}
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return false
} else {
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, key)
}
p.bindMap[normalizedKey] = kb
return true
}
func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
p.dmsBindsExists = true
}
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
section, err := p.parseFileWithSource(mainConfig, "")
if err != nil {
return nil, err
}
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath, section)
}
return section, nil
}
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, err
}
if p.processedFiles[absPath] {
return &HyprlandSection{Name: sectionName}, nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
prevSource := p.currentSource
p.currentSource = absPath
section := &HyprlandSection{Name: sectionName}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, section, filepath.Dir(absPath))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = p.currentSource
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
return section, nil
}
func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
p.includeCount++
if isDMSSource {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
return
}
includedSection, err := p.parseFileWithSource(expanded, "")
if err != nil {
return
}
section.Children = append(section.Children, *includedSection)
}
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return
}
prevSource := p.currentSource
p.currentSource = dmsBindsPath
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = dmsBindsPath
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
p.dmsProcessed = true
}
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return nil
}
// Extract bind type and flags from the left side of "="
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
keys := parts[1]
keyParts := strings.SplitN(keys, "#", 2)
keys = keyParts[0]
var comment string
if len(keyParts) > 1 {
comment = strings.TrimSpace(keyParts[1])
}
// For bindd, the format is: bindd = MODS, key, description, dispatcher, params
// For regular binds: bind = MODS, key, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4 // mods, key, description, dispatcher
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3 // mods, key, dispatcher
dispatcherIndex = 2
}
keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params
if len(keyFields) < minFields {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
var dispatcher, params string
if hasDescFlag {
// bindd format: description is in the bind itself
if comment == "" {
comment = strings.TrimSpace(keyFields[descIndex])
}
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
if len(keyFields) > dispatcherIndex+1 {
paramParts := keyFields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
if len(keyFields) > dispatcherIndex+1 {
paramParts := keyFields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
if comment != "" && strings.HasPrefix(comment, HideComment) {
return nil
}
if comment == "" {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
idx := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-idx > 1 {
modList = append(modList, modstring[idx:index])
}
idx = index + 1
}
}
}
return &HyprlandKeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
Flags: flags,
}
}
// extractBindFlags extracts the flags from a bind type string
// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d"
func extractBindFlags(bindType string) string {
bindType = strings.TrimSpace(bindType)
if !strings.HasPrefix(bindType, "bind") {
return ""
}
return bindType[4:] // Everything after "bind"
}
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
parser := NewHyprlandParser(path)
section, err := parser.ParseWithDMS()
if err != nil {
return nil, err
}
return &HyprlandParseResult{
Section: section,
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
}, nil
}

View File

@@ -130,7 +130,7 @@ func TestHyprlandGetKeybindAtLine(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser("") parser := NewHyprlandParser()
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)
@@ -187,7 +187,7 @@ bind = SUPER, right, movefocus, r
bind = SUPER, T, exec, kitty # Terminal bind = SUPER, T, exec, kitty # Terminal
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -245,7 +245,7 @@ bind = SUPER, B, exec, app2
#/# = SUPER, C, exec, app3 # Custom comment #/# = SUPER, C, exec, app3 # Custom comment
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -278,14 +278,14 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) {
content1 := "bind = SUPER, Q, killactive\n" content1 := "bind = SUPER, Q, killactive\n"
content2 := "bind = SUPER, T, exec, kitty\n" content2 := "bind = SUPER, T, exec, kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil { if err := os.WriteFile(file1, []byte(content1), 0644); err != nil {
t.Fatalf("Failed to write file1: %v", err) t.Fatalf("Failed to write file1: %v", err)
} }
if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil { if err := os.WriteFile(file2, []byte(content2), 0644); err != nil {
t.Fatalf("Failed to write file2: %v", err) t.Fatalf("Failed to write file2: %v", err)
} }
parser := NewHyprlandParser("") parser := NewHyprlandParser()
if err := parser.ReadContent(tmpDir); err != nil { if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err) t.Fatalf("ReadContent failed: %v", err)
} }
@@ -328,13 +328,13 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
} }
tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name()) tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { if err := os.MkdirAll(tmpSubdir, 0755); err != nil {
t.Skip("Cannot create test directory in home") t.Skip("Cannot create test directory in home")
} }
defer os.RemoveAll(tmpSubdir) defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "test.conf") configFile := filepath.Join(tmpSubdir, "test.conf")
if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0o644); err != nil { if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -343,7 +343,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
t.Skip("Cannot create relative path") t.Skip("Cannot create relative path")
} }
parser := NewHyprlandParser("") parser := NewHyprlandParser()
tildePathMatch := "~/" + relPath tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch) err = parser.ReadContent(tildePathMatch)
@@ -353,7 +353,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
} }
func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) { func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) {
parser := NewHyprlandParser("") parser := NewHyprlandParser()
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"} parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)
@@ -381,7 +381,7 @@ bind = SUPER, Q, killactive
bind = SUPER, T, exec, kitty bind = SUPER, T, exec, kitty
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -394,126 +394,3 @@ bind = SUPER, T, exec, kitty
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds)) t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
} }
} }
func TestExtractBindFlags(t *testing.T) {
tests := []struct {
bindType string
expected string
}{
{"bind", ""},
{"binde", "e"},
{"bindl", "l"},
{"bindr", "r"},
{"bindd", "d"},
{"bindo", "o"},
{"bindel", "el"},
{"bindler", "ler"},
{"bindem", "em"},
{" bind ", ""},
{" binde ", "e"},
{"notbind", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.bindType, func(t *testing.T) {
result := extractBindFlags(tt.bindType)
if result != tt.expected {
t.Errorf("extractBindFlags(%q) = %q, want %q", tt.bindType, result, tt.expected)
}
})
}
}
func TestHyprlandBindFlags(t *testing.T) {
tests := []struct {
name string
line string
expectedFlags string
expectedKey string
expectedDisp string
expectedDesc string
}{
{
name: "regular bind",
line: "bind = SUPER, Q, killactive",
expectedFlags: "",
expectedKey: "Q",
expectedDisp: "killactive",
expectedDesc: "Close window",
},
{
name: "binde (repeat on hold)",
line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
expectedFlags: "e",
expectedKey: "XF86AudioRaiseVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
},
{
name: "bindl (locked/inhibitor bypass)",
line: "bindl = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
expectedFlags: "l",
expectedKey: "XF86AudioLowerVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
},
{
name: "bindr (release trigger)",
line: "bindr = SUPER, SUPER_L, exec, pkill wofi || wofi",
expectedFlags: "r",
expectedKey: "SUPER_L",
expectedDisp: "exec",
expectedDesc: "pkill wofi || wofi",
},
{
name: "bindd (description)",
line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty",
expectedFlags: "d",
expectedKey: "Q",
expectedDisp: "exec",
expectedDesc: "Open my favourite terminal",
},
{
name: "bindo (long press)",
line: "bindo = SUPER, XF86AudioNext, exec, playerctl next",
expectedFlags: "o",
expectedKey: "XF86AudioNext",
expectedDisp: "exec",
expectedDesc: "playerctl next",
},
{
name: "bindel (combined flags)",
line: "bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
expectedFlags: "el",
expectedKey: "XF86AudioRaiseVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if result == nil {
t.Fatal("Expected keybind, got nil")
}
if result.Flags != tt.expectedFlags {
t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags)
}
if result.Key != tt.expectedKey {
t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey)
}
if result.Dispatcher != tt.expectedDisp {
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp)
}
if result.Comment != tt.expectedDesc {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc)
}
})
}
}

View File

@@ -7,30 +7,35 @@ import (
) )
func TestNewHyprlandProvider(t *testing.T) { func TestNewHyprlandProvider(t *testing.T) {
t.Run("custom path", func(t *testing.T) { tests := []struct {
p := NewHyprlandProvider("/custom/path") name string
if p == nil { configPath string
t.Fatal("NewHyprlandProvider returned nil") wantPath string
} }{
if p.configPath != "/custom/path" { {
t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path") name: "custom path",
} configPath: "/custom/path",
}) wantPath: "/custom/path",
},
{
name: "empty path defaults",
configPath: "",
wantPath: "$HOME/.config/hypr",
},
}
t.Run("empty path defaults", func(t *testing.T) { for _, tt := range tests {
p := NewHyprlandProvider("") t.Run(tt.name, func(t *testing.T) {
if p == nil { p := NewHyprlandProvider(tt.configPath)
t.Fatal("NewHyprlandProvider returned nil") if p == nil {
} t.Fatal("NewHyprlandProvider returned nil")
configDir, err := os.UserConfigDir() }
if err != nil {
t.Fatalf("UserConfigDir failed: %v", err) if p.configPath != tt.wantPath {
} t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath)
expected := filepath.Join(configDir, "hypr") }
if p.configPath != expected { })
t.Errorf("configPath = %q, want %q", p.configPath, expected) }
}
})
} }
func TestHyprlandProviderName(t *testing.T) { func TestHyprlandProviderName(t *testing.T) {
@@ -57,7 +62,7 @@ bind = SUPER, T, exec, kitty # Terminal
bind = SUPER, 1, workspace, 1 bind = SUPER, 1, workspace, 1
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -104,7 +109,7 @@ func TestHyprlandProviderGetCheatSheetError(t *testing.T) {
func TestFormatKey(t *testing.T) { func TestFormatKey(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "hyprland.conf") configFile := filepath.Join(tmpDir, "test.conf")
tests := []struct { tests := []struct {
name string name string
@@ -134,7 +139,7 @@ func TestFormatKey(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -158,7 +163,7 @@ func TestFormatKey(t *testing.T) {
func TestDescriptionFallback(t *testing.T) { func TestDescriptionFallback(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "hyprland.conf") configFile := filepath.Join(tmpDir, "test.conf")
tests := []struct { tests := []struct {
name string name string
@@ -189,7 +194,7 @@ func TestDescriptionFallback(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -12,7 +12,7 @@ func TestNewJSONFileProvider(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.json") testFile := filepath.Join(tmpDir, "test.json")
if err := os.WriteFile(testFile, []byte("{}"), 0o644); err != nil { if err := os.WriteFile(testFile, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err) t.Fatalf("Failed to create test file: %v", err)
} }
@@ -81,7 +81,7 @@ func TestJSONFileProviderGetCheatSheet(t *testing.T) {
} }
}` }`
if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err) t.Fatalf("Failed to write test file: %v", err)
} }
@@ -135,7 +135,7 @@ func TestJSONFileProviderGetCheatSheetNoProvider(t *testing.T) {
"binds": {} "binds": {}
}` }`
if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err) t.Fatalf("Failed to write test file: %v", err)
} }
@@ -181,7 +181,7 @@ func TestJSONFileProviderFlatArrayBackwardsCompat(t *testing.T) {
] ]
}` }`
if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err) t.Fatalf("Failed to write test file: %v", err)
} }
@@ -216,7 +216,7 @@ func TestJSONFileProviderInvalidJSON(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "invalid.json") testFile := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(testFile, []byte("not valid json"), 0o644); err != nil { if err := os.WriteFile(testFile, []byte("not valid json"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err) t.Fatalf("Failed to write test file: %v", err)
} }

View File

@@ -2,94 +2,46 @@ package providers
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"sort"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
type MangoWCProvider struct { type MangoWCProvider struct {
configPath string configPath string
dmsBindsIncluded bool
parsed bool
} }
func NewMangoWCProvider(configPath string) *MangoWCProvider { func NewMangoWCProvider(configPath string) *MangoWCProvider {
if configPath == "" { if configPath == "" {
configPath = defaultMangoWCConfigDir() configPath = "$HOME/.config/mango"
} }
return &MangoWCProvider{ return &MangoWCProvider{
configPath: configPath, configPath: configPath,
} }
} }
func defaultMangoWCConfigDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(configDir, "mango")
}
func (m *MangoWCProvider) Name() string { func (m *MangoWCProvider) Name() string {
return "mangowc" return "mangowc"
} }
func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
result, err := ParseMangoWCKeysWithDMS(m.configPath) keybinds_list, err := ParseMangoWCKeys(m.configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse mangowc config: %w", err) return nil, fmt.Errorf("failed to parse mangowc config: %w", err)
} }
m.dmsBindsIncluded = result.DMSBindsIncluded
m.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind) categorizedBinds := make(map[string][]keybinds.Keybind)
for _, kb := range result.Keybinds { for _, kb := range keybinds_list {
category := m.categorizeByCommand(kb.Command) category := m.categorizeByCommand(kb.Command)
bind := m.convertKeybind(&kb, result.ConflictingConfigs) bind := m.convertKeybind(&kb)
categorizedBinds[category] = append(categorizedBinds[category], bind) categorizedBinds[category] = append(categorizedBinds[category], bind)
} }
sheet := &keybinds.CheatSheet{ return &keybinds.CheatSheet{
Title: "MangoWC Keybinds", Title: "MangoWC Keybinds",
Provider: m.Name(), Provider: m.Name(),
Binds: categorizedBinds, Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded, }, nil
}
if result.DMSStatus != nil {
sheet.DMSStatus = &keybinds.DMSBindsStatus{
Exists: result.DMSStatus.Exists,
Included: result.DMSStatus.Included,
IncludePosition: result.DMSStatus.IncludePosition,
TotalIncludes: result.DMSStatus.TotalIncludes,
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
}
}
return sheet, nil
}
func (m *MangoWCProvider) HasDMSBindsIncluded() bool {
if m.parsed {
return m.dmsBindsIncluded
}
result, err := ParseMangoWCKeysWithDMS(m.configPath)
if err != nil {
return false
}
m.dmsBindsIncluded = result.DMSBindsIncluded
m.parsed = true
return m.dmsBindsIncluded
} }
func (m *MangoWCProvider) categorizeByCommand(command string) string { func (m *MangoWCProvider) categorizeByCommand(command string) string {
@@ -130,8 +82,8 @@ func (m *MangoWCProvider) categorizeByCommand(command string) string {
} }
} }
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind { func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind {
keyStr := m.formatKey(kb) key := m.formatKey(kb)
rawAction := m.formatRawAction(kb.Command, kb.Params) rawAction := m.formatRawAction(kb.Command, kb.Params)
desc := kb.Comment desc := kb.Comment
@@ -139,31 +91,11 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
desc = rawAction desc = rawAction
} }
source := "config" return keybinds.Keybind{
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") { Key: key,
source = "dms"
}
bind := keybinds.Keybind{
Key: keyStr,
Description: desc, Description: desc,
Action: rawAction, Action: rawAction,
Source: source,
} }
if source == "dms" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Comment,
Action: m.formatRawAction(conflictKb.Command, conflictKb.Params),
Source: "config",
}
}
}
return bind
} }
func (m *MangoWCProvider) formatRawAction(command, params string) string { func (m *MangoWCProvider) formatRawAction(command, params string) string {
@@ -179,264 +111,3 @@ func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string {
parts = append(parts, kb.Key) parts = append(parts, kb.Key)
return strings.Join(parts, "+") return strings.Join(parts, "+")
} }
func (m *MangoWCProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(m.configPath)
if err != nil {
return filepath.Join(m.configPath, "dms", "binds.conf")
}
return filepath.Join(expanded, "dms", "binds.conf")
}
func (m *MangoWCProvider) validateAction(action string) error {
action = strings.TrimSpace(action)
switch {
case action == "":
return fmt.Errorf("action cannot be empty")
case action == "spawn" || action == "spawn ":
return fmt.Errorf("spawn command requires arguments")
case action == "spawn_shell" || action == "spawn_shell ":
return fmt.Errorf("spawn_shell command requires arguments")
case strings.HasPrefix(action, "spawn "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn "))
if rest == "" {
return fmt.Errorf("spawn command requires arguments")
}
case strings.HasPrefix(action, "spawn_shell "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell "))
if rest == "" {
return fmt.Errorf("spawn_shell command requires arguments")
}
}
return nil
}
func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error {
if err := m.validateAction(action); err != nil {
return err
}
overridePath := m.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
existingBinds, err := m.loadOverrideBinds()
if err != nil {
existingBinds = make(map[string]*mangowcOverrideBind)
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &mangowcOverrideBind{
Key: key,
Action: action,
Description: description,
Options: options,
}
return m.writeOverrideBinds(existingBinds)
}
func (m *MangoWCProvider) RemoveBind(key string) error {
existingBinds, err := m.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return m.writeOverrideBinds(existingBinds)
}
type mangowcOverrideBind struct {
Key string
Action string
Description string
Options map[string]any
}
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
overridePath := m.GetOverridePath()
binds := make(map[string]*mangowcOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
fields := strings.SplitN(bindContent, ",", 4)
if len(fields) < 3 {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
command := strings.TrimSpace(fields[2])
var params string
if len(fields) > 3 {
params = strings.TrimSpace(fields[3])
}
keyStr := m.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := command
if params != "" {
action = command + " " + params
}
binds[normalizedKey] = &mangowcOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
}
}
return binds, nil
}
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
if mods == "" || strings.EqualFold(mods, "none") {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
}
func (m *MangoWCProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"):
return 0
case strings.Contains(action, "view") || strings.Contains(action, "tag"):
return 1
case strings.Contains(action, "focus") || strings.Contains(action, "exchange") ||
strings.Contains(action, "resize") || strings.Contains(action, "move"):
return 2
case strings.Contains(action, "mon"):
return 3
case strings.HasPrefix(action, "spawn"):
return 4
case action == "quit" || action == "reload_config":
return 5
default:
return 6
}
}
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0o644)
}
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
if len(binds) == 0 {
return ""
}
bindList := make([]*mangowcOverrideBind, 0, len(binds))
for _, bind := range binds {
bindList = append(bindList, bind)
}
sort.Slice(bindList, func(i, j int) bool {
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
if pi != pj {
return pi < pj
}
return bindList[i].Key < bindList[j].Key
})
var sb strings.Builder
for _, bind := range bindList {
m.writeBindLine(&sb, bind)
}
return sb.String()
}
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
mods, key := m.parseKeyString(bind.Key)
command, params := m.parseAction(bind.Action)
sb.WriteString("bind=")
if mods == "" {
sb.WriteString("none")
} else {
sb.WriteString(mods)
}
sb.WriteString(",")
sb.WriteString(key)
sb.WriteString(",")
sb.WriteString(command)
if params != "" {
sb.WriteString(",")
sb.WriteString(params)
}
if bind.Description != "" {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
default:
return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1]
}
}
func (m *MangoWCProvider) parseAction(action string) (command, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
return parts[0], ""
default:
return parts[0], parts[1]
}
}

View File

@@ -21,40 +21,17 @@ type MangoWCKeyBinding struct {
Command string `json:"command"` Command string `json:"command"`
Params string `json:"params"` Params string `json:"params"`
Comment string `json:"comment"` Comment string `json:"comment"`
Source string `json:"source"`
} }
type MangoWCParser struct { type MangoWCParser struct {
contentLines []string contentLines []string
readingLine int readingLine int
configDir string
currentSource string
dmsBindsExists bool
dmsBindsIncluded bool
includeCount int
dmsIncludePos int
bindsAfterDMS int
dmsBindKeys map[string]bool
configBindKeys map[string]bool
conflictingConfigs map[string]*MangoWCKeyBinding
bindMap map[string]*MangoWCKeyBinding
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
} }
func NewMangoWCParser(configDir string) *MangoWCParser { func NewMangoWCParser() *MangoWCParser {
return &MangoWCParser{ return &MangoWCParser{
contentLines: []string{}, contentLines: []string{},
readingLine: 0, readingLine: 0,
configDir: configDir,
dmsIncludePos: -1,
dmsBindKeys: make(map[string]bool),
configBindKeys: make(map[string]bool),
conflictingConfigs: make(map[string]*MangoWCKeyBinding),
bindMap: make(map[string]*MangoWCKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
} }
} }
@@ -317,297 +294,9 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
} }
func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) { func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) {
parser := NewMangoWCParser(path) parser := NewMangoWCParser()
if err := parser.ReadContent(path); err != nil { if err := parser.ReadContent(path); err != nil {
return nil, err return nil, err
} }
return parser.ParseKeys(), nil return parser.ParseKeys(), nil
} }
type MangoWCParseResult struct {
Keybinds []MangoWCKeyBinding
DMSBindsIncluded bool
DMSStatus *MangoWCDMSStatus
ConflictingConfigs map[string]*MangoWCKeyBinding
}
type MangoWCDMSStatus struct {
Exists bool
Included bool
IncludePosition int
TotalIncludes int
BindsAfterDMS int
Effective bool
OverriddenBy int
StatusMessage string
}
func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus {
status := &MangoWCDMSStatus{
Exists: p.dmsBindsExists,
Included: p.dmsBindsIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
}
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
status.StatusMessage = "Some DMS binds may be overridden by config binds"
default:
status.Effective = true
status.StatusMessage = "DMS binds are active"
}
return status
}
func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (p *MangoWCParser) normalizeKey(key string) string {
return strings.ToLower(key)
}
func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf")
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return
} else {
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, key)
}
p.bindMap[normalizedKey] = kb
}
func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
p.dmsBindsExists = true
}
mainConfig := filepath.Join(expandedDir, "config.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
mainConfig = filepath.Join(expandedDir, "mango.conf")
}
_, err = p.parseFileWithSource(mainConfig)
if err != nil {
return nil, err
}
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath)
}
var keybinds []MangoWCKeyBinding
for _, key := range p.bindOrder {
normalizedKey := p.normalizeKey(key)
if kb, exists := p.bindMap[normalizedKey]; exists {
keybinds = append(keybinds, *kb)
}
}
return keybinds, nil
}
func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, err
}
if p.processedFiles[absPath] {
return nil, nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
prevSource := p.currentSource
p.currentSource = absPath
var keybinds []MangoWCKeyBinding
lines := strings.Split(string(data), "\n")
for lineNum, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
continue
}
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.getKeybindAtLineContent(line, lineNum)
if kb == nil {
continue
}
kb.Source = p.currentSource
p.addBind(kb)
keybinds = append(keybinds, *kb)
}
p.currentSource = prevSource
return keybinds, nil
}
func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
p.includeCount++
if isDMSSource {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
expanded, err := utils.ExpandPath(sourcePath)
if err != nil {
return
}
fullPath := expanded
if !filepath.IsAbs(expanded) {
fullPath = filepath.Join(baseDir, expanded)
}
includedBinds, err := p.parseFileWithSource(fullPath)
if err != nil {
return
}
*keybinds = append(*keybinds, includedBinds...)
}
func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding {
keybinds, err := p.parseFileWithSource(dmsBindsPath)
if err != nil {
return nil
}
p.dmsProcessed = true
return keybinds
}
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
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, MangoWCHideComment) {
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 = mangowcAutogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(MangoWCModSeparators[0])
idx := 0
for index, char := range modstring {
isModSep := false
for _, sep := range MangoWCModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-idx > 1 {
modList = append(modList, modstring[idx:index])
}
idx = index + 1
}
}
}
return &MangoWCKeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
}
func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) {
parser := NewMangoWCParser(path)
keybinds, err := parser.ParseWithDMS()
if err != nil {
return nil, err
}
return &MangoWCParseResult{
Keybinds: keybinds,
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
}, nil
}

View File

@@ -172,7 +172,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("") parser := NewMangoWCParser()
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)
@@ -238,7 +238,7 @@ bind=Ctrl,1,view,1,0
bind=Ctrl,2,view,2,0 bind=Ctrl,2,view,2,0
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -276,14 +276,14 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) {
content1 := "bind=ALT,q,killclient,\n" content1 := "bind=ALT,q,killclient,\n"
content2 := "bind=Alt,t,spawn,kitty\n" content2 := "bind=Alt,t,spawn,kitty\n"
if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil { if err := os.WriteFile(file1, []byte(content1), 0644); err != nil {
t.Fatalf("Failed to write file1: %v", err) t.Fatalf("Failed to write file1: %v", err)
} }
if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil { if err := os.WriteFile(file2, []byte(content2), 0644); err != nil {
t.Fatalf("Failed to write file2: %v", err) t.Fatalf("Failed to write file2: %v", err)
} }
parser := NewMangoWCParser("") parser := NewMangoWCParser()
if err := parser.ReadContent(tmpDir); err != nil { if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err) t.Fatalf("ReadContent failed: %v", err)
} }
@@ -300,11 +300,11 @@ func TestMangoWCReadContentSingleFile(t *testing.T) {
content := "bind=ALT,q,killclient,\n" content := "bind=ALT,q,killclient,\n"
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write config: %v", err) t.Fatalf("Failed to write config: %v", err)
} }
parser := NewMangoWCParser("") parser := NewMangoWCParser()
if err := parser.ReadContent(configFile); err != nil { if err := parser.ReadContent(configFile); err != nil {
t.Fatalf("ReadContent failed: %v", err) t.Fatalf("ReadContent failed: %v", err)
} }
@@ -347,13 +347,13 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
} }
tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name()) tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { if err := os.MkdirAll(tmpSubdir, 0755); err != nil {
t.Skip("Cannot create test directory in home") t.Skip("Cannot create test directory in home")
} }
defer os.RemoveAll(tmpSubdir) defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "config.conf") configFile := filepath.Join(tmpSubdir, "config.conf")
if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0o644); err != nil { if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -362,7 +362,7 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
t.Skip("Cannot create relative path") t.Skip("Cannot create relative path")
} }
parser := NewMangoWCParser("") parser := NewMangoWCParser()
tildePathMatch := "~/" + relPath tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch) err = parser.ReadContent(tildePathMatch)
@@ -384,7 +384,7 @@ bind=ALT,q,killclient,
bind=Alt,t,spawn,kitty bind=Alt,t,spawn,kitty
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -419,7 +419,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("") parser := NewMangoWCParser()
parser.contentLines = []string{tt.line} parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0) result := parser.getKeybindAtLine(0)
@@ -458,7 +458,7 @@ bind=Ctrl,2,view,2,0
bind=Ctrl,3,view,3,0 bind=Ctrl,3,view,3,0
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -15,17 +15,8 @@ func TestMangoWCProviderName(t *testing.T) {
func TestMangoWCProviderDefaultPath(t *testing.T) { func TestMangoWCProviderDefaultPath(t *testing.T) {
provider := NewMangoWCProvider("") provider := NewMangoWCProvider("")
configDir, err := os.UserConfigDir() if provider.configPath != "$HOME/.config/mango" {
if err != nil { t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango")
// Fall back to testing for non-empty path
if provider.configPath == "" {
t.Error("configPath should not be empty")
}
return
}
expected := filepath.Join(configDir, "mango")
if provider.configPath != expected {
t.Errorf("configPath = %q, want %q", provider.configPath, expected)
} }
} }
@@ -183,7 +174,7 @@ func TestMangoWCConvertKeybind(t *testing.T) {
provider := NewMangoWCProvider("") provider := NewMangoWCProvider("")
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := provider.convertKeybind(tt.keybind, nil) result := provider.convertKeybind(tt.keybind)
if result.Key != tt.wantKey { if result.Key != tt.wantKey {
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey) t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
} }
@@ -223,7 +214,7 @@ bind=SUPER,n,switch_layout
bind=ALT+SHIFT,X,incgaps,1 bind=ALT+SHIFT,X,incgaps,1
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -285,7 +276,7 @@ bind=ALT,Left,focusdir,left
bind=Ctrl,1,view,1,0 bind=Ctrl,1,view,1,0
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -187,15 +187,7 @@ func (n *NiriProvider) formatRawAction(action string, args []string) string {
} }
} }
quotedArgs := make([]string, len(args)) return action + " " + strings.Join(args, " ")
for i, arg := range args {
if arg == "" {
quotedArgs[i] = `""`
} else {
quotedArgs[i] = arg
}
}
return action + " " + strings.Join(quotedArgs, " ")
} }
func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string { func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string {
@@ -235,7 +227,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
overridePath := n.GetOverridePath() overridePath := n.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err) return fmt.Errorf("failed to create dms directory: %w", err)
} }
@@ -301,15 +293,9 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
continue continue
} }
keyStr := parser.formatBindKey(kb) keyStr := parser.formatBindKey(kb)
action := n.buildActionFromNode(child)
if action == "" {
action = n.formatRawAction(kb.Action, kb.Args)
}
binds[keyStr] = &overrideBind{ binds[keyStr] = &overrideBind{
Key: keyStr, Key: keyStr,
Action: action, Action: n.formatRawAction(kb.Action, kb.Args),
Description: kb.Description, Description: kb.Description,
Options: n.extractOptions(child), Options: n.extractOptions(child),
} }
@@ -319,42 +305,6 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
return binds, nil return binds, nil
} }
func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
if len(bindNode.Children) == 0 {
return ""
}
actionNode := bindNode.Children[0]
actionName := actionNode.Name.String()
if actionName == "" {
return ""
}
parts := []string{actionName}
for _, arg := range actionNode.Arguments {
val := arg.ValueString()
if val == "" {
parts = append(parts, `""`)
} else {
parts = append(parts, val)
}
}
if actionNode.Properties != nil {
if val, ok := actionNode.Properties.Get("focus"); ok {
parts = append(parts, "focus="+val.String())
}
if val, ok := actionNode.Properties.Get("show-pointer"); ok {
parts = append(parts, "show-pointer="+val.String())
}
if val, ok := actionNode.Properties.Get("write-to-disk"); ok {
parts = append(parts, "write-to-disk="+val.String())
}
}
return strings.Join(parts, " ")
}
func (n *NiriProvider) extractOptions(node *document.Node) map[string]any { func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
if node.Properties == nil { if node.Properties == nil {
return make(map[string]any) return make(map[string]any)
@@ -485,7 +435,7 @@ func (n *NiriProvider) writeOverrideBinds(binds map[string]*overrideBind) error
return err return err
} }
return os.WriteFile(overridePath, []byte(content), 0o644) return os.WriteFile(overridePath, []byte(content), 0644)
} }
func (n *NiriProvider) getBindSortPriority(action string) int { func (n *NiriProvider) getBindSortPriority(action string) int {
@@ -511,9 +461,16 @@ func (n *NiriProvider) getBindSortPriority(action string) int {
} }
} }
const dmsWarningHeader = `// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
`
func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string { func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string {
if len(binds) == 0 { if len(binds) == 0 {
return "binds {}\n" return dmsWarningHeader + "binds {}\n"
} }
var regularBinds, recentWindowsBinds []*overrideBind var regularBinds, recentWindowsBinds []*overrideBind
@@ -540,6 +497,7 @@ func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) stri
var sb strings.Builder var sb strings.Builder
sb.WriteString(dmsWarningHeader)
sb.WriteString("binds {\n") sb.WriteString("binds {\n")
for _, bind := range regularBinds { for _, bind := range regularBinds {
n.writeBindNode(&sb, bind, " ") n.writeBindNode(&sb, bind, " ")

View File

@@ -53,7 +53,7 @@ func TestNiriParseBasicBinds(t *testing.T) {
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -112,7 +112,7 @@ func TestNiriParseRecentWindows(t *testing.T) {
} }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -148,7 +148,7 @@ func TestNiriParseRecentWindows(t *testing.T) {
func TestNiriParseInclude(t *testing.T) { func TestNiriParseInclude(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, "dms") subDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(subDir, 0o755); err != nil { if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err) t.Fatalf("Failed to create subdir: %v", err)
} }
@@ -165,10 +165,10 @@ include "dms/binds.kdl"
} }
` `
if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { if err := os.WriteFile(mainConfig, []byte(mainContent), 0644); err != nil {
t.Fatalf("Failed to write main config: %v", err) t.Fatalf("Failed to write main config: %v", err)
} }
if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { if err := os.WriteFile(includeConfig, []byte(includeContent), 0644); err != nil {
t.Fatalf("Failed to write include config: %v", err) t.Fatalf("Failed to write include config: %v", err)
} }
@@ -185,7 +185,7 @@ include "dms/binds.kdl"
func TestNiriParseIncludeOverride(t *testing.T) { func TestNiriParseIncludeOverride(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, "dms") subDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(subDir, 0o755); err != nil { if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err) t.Fatalf("Failed to create subdir: %v", err)
} }
@@ -202,10 +202,10 @@ include "dms/binds.kdl"
} }
` `
if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { if err := os.WriteFile(mainConfig, []byte(mainContent), 0644); err != nil {
t.Fatalf("Failed to write main config: %v", err) t.Fatalf("Failed to write main config: %v", err)
} }
if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { if err := os.WriteFile(includeConfig, []byte(includeContent), 0644); err != nil {
t.Fatalf("Failed to write include config: %v", err) t.Fatalf("Failed to write include config: %v", err)
} }
@@ -246,10 +246,10 @@ include "other.kdl"
include "config.kdl" include "config.kdl"
` `
if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { if err := os.WriteFile(mainConfig, []byte(mainContent), 0644); err != nil {
t.Fatalf("Failed to write main config: %v", err) t.Fatalf("Failed to write main config: %v", err)
} }
if err := os.WriteFile(otherConfig, []byte(otherContent), 0o644); err != nil { if err := os.WriteFile(otherConfig, []byte(otherContent), 0644); err != nil {
t.Fatalf("Failed to write other config: %v", err) t.Fatalf("Failed to write other config: %v", err)
} }
@@ -272,7 +272,7 @@ func TestNiriParseMissingInclude(t *testing.T) {
} }
include "nonexistent/file.kdl" include "nonexistent/file.kdl"
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -301,7 +301,7 @@ input {
} }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -348,7 +348,7 @@ func TestNiriBindOverrideBehavior(t *testing.T) {
Mod+T hotkey-overlay-title="Third" { spawn "third"; } Mod+T hotkey-overlay-title="Third" { spawn "third"; }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -386,7 +386,7 @@ func TestNiriBindOverrideBehavior(t *testing.T) {
func TestNiriBindOverrideWithIncludes(t *testing.T) { func TestNiriBindOverrideWithIncludes(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, "custom") subDir := filepath.Join(tmpDir, "custom")
if err := os.MkdirAll(subDir, 0o755); err != nil { if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err) t.Fatalf("Failed to create subdir: %v", err)
} }
@@ -409,10 +409,10 @@ binds {
} }
` `
if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { if err := os.WriteFile(mainConfig, []byte(mainContent), 0644); err != nil {
t.Fatalf("Failed to write main config: %v", err) t.Fatalf("Failed to write main config: %v", err)
} }
if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { if err := os.WriteFile(includeConfig, []byte(includeContent), 0644); err != nil {
t.Fatalf("Failed to write include config: %v", err) t.Fatalf("Failed to write include config: %v", err)
} }
@@ -471,7 +471,7 @@ func TestNiriParseMultipleArgs(t *testing.T) {
} }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -508,7 +508,7 @@ func TestNiriParseNumericWorkspaceBinds(t *testing.T) {
Mod+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-column-to-workspace 1; } Mod+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-column-to-workspace 1; }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -550,7 +550,7 @@ func TestNiriParseQuotedStringArgs(t *testing.T) {
Super+Shift+Minus hotkey-overlay-title="Adjust Window Height -10%" { set-window-height "-10%"; } Super+Shift+Minus hotkey-overlay-title="Adjust Window Height -10%" { set-window-height "-10%"; }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -586,7 +586,7 @@ func TestNiriParseActionWithProperties(t *testing.T) {
Alt+Tab { next-window scope="output"; } Alt+Tab { next-window scope="output"; }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -6,6 +6,13 @@ import (
"testing" "testing"
) )
const testHeader = `// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
`
func TestNiriProviderName(t *testing.T) { func TestNiriProviderName(t *testing.T) {
provider := NewNiriProvider("") provider := NewNiriProvider("")
if provider.Name() != "niri" { if provider.Name() != "niri" {
@@ -27,7 +34,7 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
Mod+Shift+E { quit; } Mod+Shift+E { quit; }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -121,8 +128,6 @@ func TestNiriFormatRawAction(t *testing.T) {
}{ }{
{"spawn", []string{"kitty"}, "spawn kitty"}, {"spawn", []string{"kitty"}, "spawn kitty"},
{"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"}, {"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"},
{"spawn", []string{"dms", "ipc", "call", "brightness", "increment", "5", ""}, `spawn dms ipc call brightness increment 5 ""`},
{"spawn", []string{"dms", "ipc", "call", "dash", "toggle", ""}, `spawn dms ipc call dash toggle ""`},
{"close-window", nil, "close-window"}, {"close-window", nil, "close-window"},
{"fullscreen-window", nil, "fullscreen-window"}, {"fullscreen-window", nil, "fullscreen-window"},
{"focus-workspace", []string{"1"}, "focus-workspace 1"}, {"focus-workspace", []string{"1"}, "focus-workspace 1"},
@@ -199,7 +204,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
{ {
name: "empty binds", name: "empty binds",
binds: map[string]*overrideBind{}, binds: map[string]*overrideBind{},
expected: "binds {}\n", expected: testHeader + "binds {}\n",
}, },
{ {
name: "simple spawn bind", name: "simple spawn bind",
@@ -210,7 +215,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Open Terminal", Description: "Open Terminal",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
} }
`, `,
@@ -224,7 +229,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Application Launcher", Description: "Application Launcher",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; } Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
} }
`, `,
@@ -238,7 +243,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Options: map[string]any{"allow-when-locked": true}, Options: map[string]any{"allow-when-locked": true},
}, },
}, },
expected: `binds { expected: testHeader + `binds {
XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; } XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; }
} }
`, `,
@@ -252,7 +257,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Close Window", Description: "Close Window",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
Mod+Q hotkey-overlay-title="Close Window" { close-window; } Mod+Q hotkey-overlay-title="Close Window" { close-window; }
} }
`, `,
@@ -265,7 +270,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Action: "next-window", Action: "next-window",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
} }
recent-windows { recent-windows {
@@ -312,7 +317,7 @@ func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write temp file: %v", err) t.Fatalf("Failed to write temp file: %v", err)
} }
@@ -326,58 +331,6 @@ func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
} }
} }
func TestNiriEmptyArgsPreservation(t *testing.T) {
provider := NewNiriProvider("")
binds := map[string]*overrideBind{
"XF86MonBrightnessUp": {
Key: "XF86MonBrightnessUp",
Action: `spawn dms ipc call brightness increment 5 ""`,
Description: "Brightness Up",
},
"XF86MonBrightnessDown": {
Key: "XF86MonBrightnessDown",
Action: `spawn dms ipc call brightness decrement 5 ""`,
Description: "Brightness Down",
},
"Super+Alt+Page_Up": {
Key: "Super+Alt+Page_Up",
Action: `spawn dms ipc call dash toggle ""`,
Description: "Dashboard Toggle",
},
}
content := provider.generateBindsContent(binds)
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("Failed to create dms directory: %v", err)
}
bindsFile := filepath.Join(dmsDir, "binds.kdl")
if err := os.WriteFile(bindsFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write binds file: %v", err)
}
testProvider := NewNiriProvider(tmpDir)
loadedBinds, err := testProvider.loadOverrideBinds()
if err != nil {
t.Fatalf("Failed to load binds: %v\nContent was:\n%s", err, content)
}
for key, expected := range binds {
loaded, ok := loadedBinds[key]
if !ok {
t.Errorf("Missing bind for key %s", key)
continue
}
if loaded.Action != expected.Action {
t.Errorf("Action mismatch for %s:\n got: %q\n want: %q", key, loaded.Action, expected.Action)
}
}
}
func TestNiriProviderWithRealWorldConfig(t *testing.T) { func TestNiriProviderWithRealWorldConfig(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
@@ -428,7 +381,7 @@ recent-windows {
} }
} }
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -469,7 +422,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Focus Workspace 1", Description: "Focus Workspace 1",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; } Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; }
} }
`, `,
@@ -483,7 +436,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Focus Workspace 10", Description: "Focus Workspace 10",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; } Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; }
} }
`, `,
@@ -497,7 +450,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Adjust Column Width -10%", Description: "Adjust Column Width -10%",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; } Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; }
} }
`, `,
@@ -511,7 +464,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Adjust Column Width +10%", Description: "Adjust Column Width +10%",
}, },
}, },
expected: `binds { expected: testHeader + `binds {
Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; } Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; }
} }
`, `,
@@ -540,7 +493,7 @@ func TestNiriGenerateActionWithUnquotedPercentArg(t *testing.T) {
} }
content := provider.generateBindsContent(binds) content := provider.generateBindsContent(binds)
expected := `binds { expected := testHeader + `binds {
Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; } Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; }
} }
` `
@@ -561,7 +514,7 @@ func TestNiriGenerateSpawnWithNumericArgs(t *testing.T) {
} }
content := provider.generateBindsContent(binds) content := provider.generateBindsContent(binds)
expected := `binds { expected := testHeader + `binds {
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
} }
` `
@@ -582,7 +535,7 @@ func TestNiriGenerateSpawnNumericArgFromCLI(t *testing.T) {
} }
content := provider.generateBindsContent(binds) content := provider.generateBindsContent(binds)
expected := `binds { expected := testHeader + `binds {
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
} }
` `
@@ -621,7 +574,7 @@ func TestNiriGenerateWorkspaceBindsRoundTrip(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write temp file: %v", err) t.Fatalf("Failed to write temp file: %v", err)
} }

View File

@@ -200,7 +200,7 @@ bindsym $mod+t exec $term
bindsym $mod+d exec $menu bindsym $mod+d exec $menu
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -247,7 +247,7 @@ bindsym $mod+Right focus right
bindsym $mod+t exec kitty # Terminal bindsym $mod+t exec kitty # Terminal
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -328,13 +328,13 @@ func TestSwayReadContentWithTildeExpansion(t *testing.T) {
} }
tmpSubdir := filepath.Join(homeDir, ".config", "test-sway-"+t.Name()) tmpSubdir := filepath.Join(homeDir, ".config", "test-sway-"+t.Name())
if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { if err := os.MkdirAll(tmpSubdir, 0755); err != nil {
t.Skip("Cannot create test directory in home") t.Skip("Cannot create test directory in home")
} }
defer os.RemoveAll(tmpSubdir) defer os.RemoveAll(tmpSubdir)
configFile := filepath.Join(tmpSubdir, "config") configFile := filepath.Join(tmpSubdir, "config")
if err := os.WriteFile(configFile, []byte("bindsym Mod4+q kill\n"), 0o644); err != nil { if err := os.WriteFile(configFile, []byte("bindsym Mod4+q kill\n"), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -365,7 +365,7 @@ bindsym Mod4+q kill
bindsym Mod4+t exec kitty bindsym Mod4+t exec kitty
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -404,7 +404,7 @@ bindsym $mod+2 workspace number 2
bindsym $mod+Shift+1 move container to workspace number 1 bindsym $mod+Shift+1 move container to workspace number 1
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -190,7 +190,7 @@ bindsym $mod+s layout stacking
bindsym $mod+w layout tabbed bindsym $mod+w layout tabbed
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }
@@ -253,7 +253,7 @@ bindsym $mod+f fullscreen toggle
bindsym $mod+1 workspace number 1 bindsym $mod+1 workspace number 1
` `
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err) t.Fatalf("Failed to write test config: %v", err)
} }

View File

@@ -8,7 +8,6 @@ type Keybind struct {
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
HideOnOverlay bool `json:"hideOnOverlay,omitempty"` HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
CooldownMs int `json:"cooldownMs,omitempty"` CooldownMs int `json:"cooldownMs,omitempty"`
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
Conflict *Keybind `json:"conflict,omitempty"` Conflict *Keybind `json:"conflict,omitempty"`
} }

View File

@@ -16,64 +16,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
type ColorMode string
const (
ColorModeDark ColorMode = "dark"
ColorModeLight ColorMode = "light"
)
type TemplateKind int
const (
TemplateKindNormal TemplateKind = iota
TemplateKindTerminal
TemplateKindGTK
TemplateKindVSCode
)
type TemplateDef struct {
ID string
Commands []string
Flatpaks []string
ConfigFile string
Kind TemplateKind
RunUnconditionally bool
}
var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
{ID: "alacritty", Commands: []string{"alacritty"}, ConfigFile: "alacritty.toml", Kind: TemplateKindTerminal},
{ID: "wezterm", Commands: []string{"wezterm"}, ConfigFile: "wezterm.toml", Kind: TemplateKindTerminal},
{ID: "nvim", Commands: []string{"nvim"}, ConfigFile: "neovim.toml", Kind: TemplateKindTerminal},
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
{ID: "vscode", Kind: TemplateKindVSCode},
{ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml"},
}
func (c *ColorMode) GTKTheme() string {
switch *c {
case ColorModeDark:
return "adw-gtk3-dark"
default:
return "adw-gtk3"
}
}
var ( var (
matugenVersionOnce sync.Once matugenVersionOnce sync.Once
matugenSupportsCOE bool matugenSupportsCOE bool
@@ -85,7 +27,7 @@ type Options struct {
ConfigDir string ConfigDir string
Kind string Kind string
Value string Value string
Mode ColorMode Mode string
IconTheme string IconTheme string
MatugenType string MatugenType string
RunUserTemplates bool RunUserTemplates bool
@@ -93,7 +35,6 @@ type Options struct {
SyncModeWithPortal bool SyncModeWithPortal bool
TerminalsAlwaysDark bool TerminalsAlwaysDark bool
SkipTemplates string SkipTemplates string
AppChecker utils.AppChecker
} }
type ColorsOutput struct { type ColorsOutput struct {
@@ -136,7 +77,7 @@ func Run(opts Options) error {
return fmt.Errorf("value is required") return fmt.Errorf("value is required")
} }
if opts.Mode == "" { if opts.Mode == "" {
opts.Mode = ColorModeDark opts.Mode = "dark"
} }
if opts.MatugenType == "" { if opts.MatugenType == "" {
opts.MatugenType = "scheme-tonal-spot" opts.MatugenType = "scheme-tonal-spot"
@@ -144,11 +85,8 @@ func Run(opts Options) error {
if opts.IconTheme == "" { if opts.IconTheme == "" {
opts.IconTheme = "System Default" opts.IconTheme = "System Default"
} }
if opts.AppChecker == nil {
opts.AppChecker = utils.DefaultAppChecker{}
}
if err := os.MkdirAll(opts.StateDir, 0o755); err != nil { if err := os.MkdirAll(opts.StateDir, 0755); err != nil {
return fmt.Errorf("failed to create state dir: %w", err) return fmt.Errorf("failed to create state dir: %w", err)
} }
@@ -207,7 +145,7 @@ func buildOnce(opts *Options) error {
importArgs = []string{"--import-json-string", importData} importArgs = []string{"--import-json-string", importData}
log.Info("Running matugen color hex with stock color overrides") log.Info("Running matugen color hex with stock color overrides")
args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()} args := []string{"color", "hex", primaryDark, "-m", opts.Mode, "-t", opts.MatugenType, "-c", cfgFile.Name()}
args = append(args, importArgs...) args = append(args, importArgs...)
if err := runMatugen(args); err != nil { if err := runMatugen(args); err != nil {
return err return err
@@ -243,7 +181,7 @@ func buildOnce(opts *Options) error {
default: default:
args = []string{opts.Kind, opts.Value} args = []string{opts.Kind, opts.Value}
} }
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()) args = append(args, "-m", opts.Mode, "-t", opts.MatugenType, "-c", cfgFile.Name())
args = append(args, importArgs...) args = append(args, importArgs...)
if err := runMatugen(args); err != nil { if err := runMatugen(args); err != nil {
return err return err
@@ -282,7 +220,7 @@ func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
if strings.TrimSpace(line) == "[config]" { if strings.TrimSpace(line) == "[config]" {
continue continue
} }
cfgFile.WriteString(substituteVars(line, opts.ShellDir) + "\n") cfgFile.WriteString(substituteShellDir(line, opts.ShellDir) + "\n")
} }
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
} }
@@ -293,34 +231,72 @@ output_path = '%s'
`, opts.ShellDir, opts.ColorsOutput()) `, opts.ShellDir, opts.ColorsOutput())
homeDir, _ := os.UserHomeDir() if !opts.ShouldSkipTemplate("gtk") {
for _, tmpl := range templateRegistry { switch opts.Mode {
if opts.ShouldSkipTemplate(tmpl.ID) { case "light":
continue appendConfig(opts, cfgFile, "skip", "gtk3-light.toml")
}
switch tmpl.Kind {
case TemplateKindGTK:
switch opts.Mode {
case ColorModeLight:
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
default:
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
}
case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
case TemplateKindVSCode:
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
default: default:
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) appendConfig(opts, cfgFile, "skip", "gtk3-dark.toml")
} }
} }
if !opts.ShouldSkipTemplate("niri") {
appendConfig(opts, cfgFile, "niri", "niri.toml")
}
if !opts.ShouldSkipTemplate("qt5ct") {
appendConfig(opts, cfgFile, "qt5ct", "qt5ct.toml")
}
if !opts.ShouldSkipTemplate("qt6ct") {
appendConfig(opts, cfgFile, "qt6ct", "qt6ct.toml")
}
if !opts.ShouldSkipTemplate("firefox") {
appendConfig(opts, cfgFile, "firefox", "firefox.toml")
}
if !opts.ShouldSkipTemplate("pywalfox") {
appendConfig(opts, cfgFile, "pywalfox", "pywalfox.toml")
}
if !opts.ShouldSkipTemplate("vesktop") {
appendConfig(opts, cfgFile, "vesktop", "vesktop.toml")
}
if !opts.ShouldSkipTemplate("equibop") {
appendConfig(opts, cfgFile, "equibop", "equibop.toml")
}
if !opts.ShouldSkipTemplate("ghostty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "ghostty", "ghostty.toml")
}
if !opts.ShouldSkipTemplate("kitty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "kitty", "kitty.toml")
}
if !opts.ShouldSkipTemplate("foot") {
appendTerminalConfig(opts, cfgFile, tmpDir, "foot", "foot.toml")
}
if !opts.ShouldSkipTemplate("alacritty") {
appendTerminalConfig(opts, cfgFile, tmpDir, "alacritty", "alacritty.toml")
}
if !opts.ShouldSkipTemplate("wezterm") {
appendTerminalConfig(opts, cfgFile, tmpDir, "wezterm", "wezterm.toml")
}
if !opts.ShouldSkipTemplate("nvim") {
appendTerminalConfig(opts, cfgFile, tmpDir, "nvim", "neovim.toml")
}
if !opts.ShouldSkipTemplate("dgop") {
appendConfig(opts, cfgFile, "dgop", "dgop.toml")
}
if !opts.ShouldSkipTemplate("kcolorscheme") {
appendConfig(opts, cfgFile, "skip", "kcolorscheme.toml")
}
if !opts.ShouldSkipTemplate("vscode") {
homeDir, _ := os.UserHomeDir()
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
}
if opts.RunUserTemplates { if opts.RunUserTemplates {
if data, err := os.ReadFile(userConfigPath); err == nil { if data, err := os.ReadFile(userConfigPath); err == nil {
templatesSection := extractTOMLSection(string(data), "[templates]", "") templatesSection := extractTOMLSection(string(data), "[templates]", "")
@@ -347,34 +323,28 @@ output_path = '%s'
return nil return nil
} }
func appendConfig( func appendConfig(opts *Options, cfgFile *os.File, checkCmd, fileName string) {
opts *Options,
cfgFile *os.File,
checkCmd []string,
checkFlatpaks []string,
fileName string,
) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName) configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
return return
} }
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { if checkCmd != "skip" && !utils.CommandExists(checkCmd) {
return return
} }
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
return return
} }
cfgFile.WriteString(substituteVars(string(data), opts.ShellDir)) cfgFile.WriteString(substituteShellDir(string(data), opts.ShellDir))
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
} }
func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkCmd []string, checkFlatpaks []string, fileName string) { func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir, checkCmd, fileName string) {
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName) configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
return return
} }
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { if checkCmd != "skip" && !utils.CommandExists(checkCmd) {
return return
} }
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
@@ -385,7 +355,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
content := string(data) content := string(data)
if !opts.TerminalsAlwaysDark { if !opts.TerminalsAlwaysDark {
cfgFile.WriteString(substituteVars(content, opts.ShellDir)) cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
return return
} }
@@ -414,7 +384,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
modified := strings.ReplaceAll(string(origData), ".default.", ".dark.") modified := strings.ReplaceAll(string(origData), ".default.", ".dark.")
tmpPath := filepath.Join(tmpDir, templateName) tmpPath := filepath.Join(tmpDir, templateName)
if err := os.WriteFile(tmpPath, []byte(modified), 0o644); err != nil { if err := os.WriteFile(tmpPath, []byte(modified), 0644); err != nil {
continue continue
} }
@@ -423,32 +393,14 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
fmt.Sprintf("'%s'", tmpPath)) fmt.Sprintf("'%s'", tmpPath))
} }
cfgFile.WriteString(substituteVars(content, opts.ShellDir)) cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
cfgFile.WriteString("\n") cfgFile.WriteString("\n")
} }
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool { func appendVSCodeConfig(cfgFile *os.File, name, extDir, shellDir string) {
// Both nil is treated as "skip check" / unconditionally run if _, err := os.Stat(extDir); err != nil {
if checkCmd == nil && checkFlatpaks == nil {
return true
}
if checkCmd != nil && checker.AnyCommandExists(checkCmd...) {
return true
}
if checkFlatpaks != nil && checker.AnyFlatpakExists(checkFlatpaks...) {
return true
}
return false
}
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return return
} }
extDir := matches[0]
templateDir := filepath.Join(shellDir, "matugen", "templates") templateDir := filepath.Join(shellDir, "matugen", "templates")
fmt.Fprintf(cfgFile, `[templates.dms%sdefault] fmt.Fprintf(cfgFile, `[templates.dms%sdefault]
input_path = '%s/vscode-color-theme-default.json' input_path = '%s/vscode-color-theme-default.json'
@@ -468,12 +420,8 @@ output_path = '%s/themes/dankshell-light.json'
log.Infof("Added %s theme config (extension found at %s)", name, extDir) log.Infof("Added %s theme config (extension found at %s)", name, extDir)
} }
func substituteVars(content, shellDir string) string { func substituteShellDir(content, shellDir string) string {
result := strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/") return strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
return result
} }
func extractTOMLSection(content, startMarker, endMarker string) string { func extractTOMLSection(content, startMarker, endMarker string) string {
@@ -605,19 +553,19 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
return color return color
} }
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string { func generateDank16Variants(primaryDark, primaryLight, surface, mode string) string {
variantOpts := dank16.VariantOptions{ variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark, PrimaryDark: primaryDark,
PrimaryLight: primaryLight, PrimaryLight: primaryLight,
Background: surface, Background: surface,
UseDPS: true, UseDPS: true,
IsLightMode: mode == ColorModeLight, IsLightMode: mode == "light",
} }
variantColors := dank16.GenerateVariantPalette(variantOpts) variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors) return dank16.GenerateVariantJSON(variantColors)
} }
func refreshGTK(configDir string, mode ColorMode) { func refreshGTK(configDir, mode string) {
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css") gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
info, err := os.Lstat(gtkCSS) info, err := os.Lstat(gtkCSS)
@@ -643,7 +591,7 @@ func refreshGTK(configDir string, mode ColorMode) {
} }
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run() exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run()
exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()).Run() exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "adw-gtk3-"+mode).Run()
} }
func signalTerminals() { func signalTerminals() {
@@ -673,9 +621,9 @@ func signalByName(name string, sig syscall.Signal) {
} }
} }
func syncColorScheme(mode ColorMode) { func syncColorScheme(mode string) {
scheme := "prefer-dark" scheme := "prefer-dark"
if mode == ColorModeLight { if mode == "light" {
scheme = "default" scheme = "default"
} }
@@ -683,52 +631,3 @@ func syncColorScheme(mode ColorMode) {
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run() exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
} }
} }
type TemplateCheck struct {
ID string `json:"id"`
Detected bool `json:"detected"`
}
func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
if checker == nil {
checker = utils.DefaultAppChecker{}
}
homeDir, _ := os.UserHomeDir()
checks := make([]TemplateCheck, 0, len(templateRegistry))
for _, tmpl := range templateRegistry {
detected := false
switch {
case tmpl.RunUnconditionally:
detected = true
case tmpl.Kind == TemplateKindVSCode:
detected = checkVSCodeExtension(homeDir)
default:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
}
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
}
return checks
}
func checkVSCodeExtension(homeDir string) bool {
extDirs := []string{
filepath.Join(homeDir, ".vscode/extensions"),
filepath.Join(homeDir, ".vscode-oss/extensions"),
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
filepath.Join(homeDir, ".cursor/extensions"),
filepath.Join(homeDir, ".windsurf/extensions"),
}
for _, extDir := range extDirs {
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
return true
}
}
return false
}

View File

@@ -1,394 +0,0 @@
package matugen
import (
"os"
"path/filepath"
"testing"
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/stretchr/testify/assert"
)
func TestAppendConfigBinaryExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when binary exists")
}
if string(output) != testConfig+"\n" {
t.Errorf("expected %q, got %q", testConfig+"\n", string(output))
}
}
func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists().Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when binary doesn't exist, got: %q", string(output))
}
}
func TestAppendConfigFlatpakExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyFlatpakExists("app.zen_browser.zen").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when flatpak exists")
}
}
func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists().Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when flatpak doesn't exist, got: %q", string(output))
}
}
func TestAppendConfigBothExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when both binary and flatpak exist")
}
}
func TestAppendConfigNeitherExists(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when neither exists, got: %q", string(output))
}
}
func TestAppendConfigNoChecks(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
testConfig := "always include"
configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "test.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) == 0 {
t.Errorf("expected config to be written when no checks specified")
}
}
func TestAppendConfigFileDoesNotExist(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("failed to create configs dir: %v", err)
}
outFile := filepath.Join(tempDir, "output.toml")
cfgFile, err := os.Create(outFile)
if err != nil {
t.Fatalf("failed to create output file: %v", err)
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
cfgFile.Close()
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(output) != 0 {
t.Errorf("expected no config when file doesn't exist, got: %q", string(output))
}
}
func TestSubstituteVars(t *testing.T) {
configDir := utils.XDGConfigHome()
dataDir := utils.XDGDataHome()
cacheDir := utils.XDGCacheHome()
tests := []struct {
name string
input string
shellDir string
expected string
}{
{
name: "substitutes SHELL_DIR",
input: "input_path = 'SHELL_DIR/matugen/templates/foo.conf'",
shellDir: "/home/user/shell",
expected: "input_path = '/home/user/shell/matugen/templates/foo.conf'",
},
{
name: "substitutes CONFIG_DIR",
input: "output_path = 'CONFIG_DIR/kitty/theme.conf'",
shellDir: "/home/user/shell",
expected: "output_path = '" + configDir + "/kitty/theme.conf'",
},
{
name: "substitutes DATA_DIR",
input: "output_path = 'DATA_DIR/color-schemes/theme.colors'",
shellDir: "/home/user/shell",
expected: "output_path = '" + dataDir + "/color-schemes/theme.colors'",
},
{
name: "substitutes CACHE_DIR",
input: "output_path = 'CACHE_DIR/wal/colors.json'",
shellDir: "/home/user/shell",
expected: "output_path = '" + cacheDir + "/wal/colors.json'",
},
{
name: "substitutes all dir types",
input: "'SHELL_DIR/a' 'CONFIG_DIR/b' 'DATA_DIR/c' 'CACHE_DIR/d'",
shellDir: "/shell",
expected: "'/shell/a' '" + configDir + "/b' '" + dataDir + "/c' '" + cacheDir + "/d'",
},
{
name: "no substitution when no placeholders",
input: "input_path = '/absolute/path/foo.conf'",
shellDir: "/home/user/shell",
expected: "input_path = '/absolute/path/foo.conf'",
},
{
name: "multiple SHELL_DIR occurrences",
input: "'SHELL_DIR/a' and 'SHELL_DIR/b'",
shellDir: "/shell",
expected: "'/shell/a' and '/shell/b'",
},
{
name: "only substitutes quoted paths",
input: "SHELL_DIR/unquoted and 'SHELL_DIR/quoted'",
shellDir: "/shell",
expected: "SHELL_DIR/unquoted and '/shell/quoted'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := substituteVars(tc.input, tc.shellDir)
assert.Equal(t, tc.expected, result)
})
}
}

View File

@@ -1,242 +0,0 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_utils
import mock "github.com/stretchr/testify/mock"
// MockAppChecker is an autogenerated mock type for the AppChecker type
type MockAppChecker struct {
mock.Mock
}
type MockAppChecker_Expecter struct {
mock *mock.Mock
}
func (_m *MockAppChecker) EXPECT() *MockAppChecker_Expecter {
return &MockAppChecker_Expecter{mock: &_m.Mock}
}
// AnyCommandExists provides a mock function with given fields: cmds
func (_m *MockAppChecker) AnyCommandExists(cmds ...string) bool {
_va := make([]interface{}, len(cmds))
for _i := range cmds {
_va[_i] = cmds[_i]
}
var _ca []interface{}
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for AnyCommandExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(...string) bool); ok {
r0 = rf(cmds...)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_AnyCommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyCommandExists'
type MockAppChecker_AnyCommandExists_Call struct {
*mock.Call
}
// AnyCommandExists is a helper method to define mock.On call
// - cmds ...string
func (_e *MockAppChecker_Expecter) AnyCommandExists(cmds ...interface{}) *MockAppChecker_AnyCommandExists_Call {
return &MockAppChecker_AnyCommandExists_Call{Call: _e.mock.On("AnyCommandExists",
append([]interface{}{}, cmds...)...)}
}
func (_c *MockAppChecker_AnyCommandExists_Call) Run(run func(cmds ...string)) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]string, len(args)-0)
for i, a := range args[0:] {
if a != nil {
variadicArgs[i] = a.(string)
}
}
run(variadicArgs...)
})
return _c
}
func (_c *MockAppChecker_AnyCommandExists_Call) Return(_a0 bool) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_AnyCommandExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Return(run)
return _c
}
// AnyFlatpakExists provides a mock function with given fields: flatpaks
func (_m *MockAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
_va := make([]interface{}, len(flatpaks))
for _i := range flatpaks {
_va[_i] = flatpaks[_i]
}
var _ca []interface{}
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for AnyFlatpakExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(...string) bool); ok {
r0 = rf(flatpaks...)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_AnyFlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyFlatpakExists'
type MockAppChecker_AnyFlatpakExists_Call struct {
*mock.Call
}
// AnyFlatpakExists is a helper method to define mock.On call
// - flatpaks ...string
func (_e *MockAppChecker_Expecter) AnyFlatpakExists(flatpaks ...interface{}) *MockAppChecker_AnyFlatpakExists_Call {
return &MockAppChecker_AnyFlatpakExists_Call{Call: _e.mock.On("AnyFlatpakExists",
append([]interface{}{}, flatpaks...)...)}
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) Run(run func(flatpaks ...string)) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]string, len(args)-0)
for i, a := range args[0:] {
if a != nil {
variadicArgs[i] = a.(string)
}
}
run(variadicArgs...)
})
return _c
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) Return(_a0 bool) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Return(run)
return _c
}
// CommandExists provides a mock function with given fields: cmd
func (_m *MockAppChecker) CommandExists(cmd string) bool {
ret := _m.Called(cmd)
if len(ret) == 0 {
panic("no return value specified for CommandExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(cmd)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_CommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommandExists'
type MockAppChecker_CommandExists_Call struct {
*mock.Call
}
// CommandExists is a helper method to define mock.On call
// - cmd string
func (_e *MockAppChecker_Expecter) CommandExists(cmd interface{}) *MockAppChecker_CommandExists_Call {
return &MockAppChecker_CommandExists_Call{Call: _e.mock.On("CommandExists", cmd)}
}
func (_c *MockAppChecker_CommandExists_Call) Run(run func(cmd string)) *MockAppChecker_CommandExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockAppChecker_CommandExists_Call) Return(_a0 bool) *MockAppChecker_CommandExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_CommandExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_CommandExists_Call {
_c.Call.Return(run)
return _c
}
// FlatpakExists provides a mock function with given fields: name
func (_m *MockAppChecker) FlatpakExists(name string) bool {
ret := _m.Called(name)
if len(ret) == 0 {
panic("no return value specified for FlatpakExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(name)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_FlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FlatpakExists'
type MockAppChecker_FlatpakExists_Call struct {
*mock.Call
}
// FlatpakExists is a helper method to define mock.On call
// - name string
func (_e *MockAppChecker_Expecter) FlatpakExists(name interface{}) *MockAppChecker_FlatpakExists_Call {
return &MockAppChecker_FlatpakExists_Call{Call: _e.mock.On("FlatpakExists", name)}
}
func (_c *MockAppChecker_FlatpakExists_Call) Run(run func(name string)) *MockAppChecker_FlatpakExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockAppChecker_FlatpakExists_Call) Return(_a0 bool) *MockAppChecker_FlatpakExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_FlatpakExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_FlatpakExists_Call {
_c.Call.Return(run)
return _c
}
// NewMockAppChecker creates a new instance of MockAppChecker. 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 NewMockAppChecker(t interface {
mock.TestingT
Cleanup(func())
}) *MockAppChecker {
mock := &MockAppChecker{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1,170 +0,0 @@
package notify
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"github.com/godbus/dbus/v5"
)
const (
notifyDest = "org.freedesktop.Notifications"
notifyPath = "/org/freedesktop/Notifications"
notifyInterface = "org.freedesktop.Notifications"
)
type Notification struct {
AppName string
Icon string
Summary string
Body string
FilePath string
Timeout int32
}
func Send(n Notification) error {
conn, err := dbus.SessionBus()
if err != nil {
return fmt.Errorf("dbus session failed: %w", err)
}
if n.AppName == "" {
n.AppName = "DMS"
}
if n.Timeout == 0 {
n.Timeout = 5000
}
var actions []string
if n.FilePath != "" {
actions = []string{
"open", "Open",
"folder", "Open Folder",
}
}
hints := map[string]dbus.Variant{}
if n.FilePath != "" {
hints["image_path"] = dbus.MakeVariant(n.FilePath)
}
obj := conn.Object(notifyDest, notifyPath)
call := obj.Call(
notifyInterface+".Notify",
0,
n.AppName,
uint32(0),
n.Icon,
n.Summary,
n.Body,
actions,
hints,
n.Timeout,
)
if call.Err != nil {
return fmt.Errorf("notify call failed: %w", call.Err)
}
var notificationID uint32
if err := call.Store(&notificationID); err != nil {
return fmt.Errorf("failed to get notification id: %w", err)
}
if len(actions) > 0 && n.FilePath != "" {
spawnActionListener(notificationID, n.FilePath)
}
return nil
}
func spawnActionListener(notificationID uint32, filePath string) {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, "notify-action-generic", fmt.Sprintf("%d", notificationID), filePath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}
func RunActionListener(args []string) {
if len(args) < 2 {
return
}
notificationID, err := strconv.ParseUint(args[0], 10, 32)
if err != nil {
return
}
filePath := args[1]
conn, err := dbus.SessionBus()
if err != nil {
return
}
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(notifyPath),
dbus.WithMatchInterface(notifyInterface),
); err != nil {
return
}
signals := make(chan *dbus.Signal, 10)
conn.Signal(signals)
for sig := range signals {
switch sig.Name {
case notifyInterface + ".ActionInvoked":
if len(sig.Body) < 2 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok || id != uint32(notificationID) {
continue
}
action, ok := sig.Body[1].(string)
if !ok {
continue
}
handleAction(action, filePath)
return
case notifyInterface + ".NotificationClosed":
if len(sig.Body) < 1 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok || id != uint32(notificationID) {
continue
}
return
}
}
}
func handleAction(action, filePath string) {
switch action {
case "open", "default":
openPath(filePath)
case "folder":
openPath(filepath.Dir(filePath))
}
}
func openPath(path string) {
cmd := exec.Command("xdg-open", path)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}

View File

@@ -116,12 +116,12 @@ func (m *Manager) Install(plugin Plugin) error {
return fmt.Errorf("plugin already installed: %s", plugin.Name) return fmt.Errorf("plugin already installed: %s", plugin.Name)
} }
if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil { if err := m.fs.MkdirAll(m.pluginsDir, 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err) return fmt.Errorf("failed to create plugins directory: %w", err)
} }
reposDir := filepath.Join(m.pluginsDir, ".repos") reposDir := filepath.Join(m.pluginsDir, ".repos")
if err := m.fs.MkdirAll(reposDir, 0o755); err != nil { if err := m.fs.MkdirAll(reposDir, 0755); err != nil {
return fmt.Errorf("failed to create repos directory: %w", err) return fmt.Errorf("failed to create repos directory: %w", err)
} }
@@ -168,7 +168,7 @@ func (m *Manager) Install(plugin Plugin) error {
metaPath := pluginPath + ".meta" metaPath := pluginPath + ".meta"
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName) metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil { if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0644); err != nil {
return fmt.Errorf("failed to write metadata: %w", err) return fmt.Errorf("failed to write metadata: %w", err)
} }
} else { } else {

View File

@@ -66,7 +66,7 @@ func TestIsInstalled(t *testing.T) {
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID) pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0o755) err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err) require.NoError(t, err)
installed, err := manager.IsInstalled(plugin) installed, err := manager.IsInstalled(plugin)
@@ -100,7 +100,7 @@ func TestInstall(t *testing.T) {
cloneCalled = true cloneCalled = true
assert.Equal(t, filepath.Join(pluginsDir, plugin.ID), path) assert.Equal(t, filepath.Join(pluginsDir, plugin.ID), path)
assert.Equal(t, plugin.Repo, url) assert.Equal(t, plugin.Repo, url)
return fs.MkdirAll(path, 0o755) return fs.MkdirAll(path, 0755)
}, },
} }
manager.gitClient = mockGit manager.gitClient = mockGit
@@ -118,7 +118,7 @@ func TestInstall(t *testing.T) {
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID) pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0o755) err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err) require.NoError(t, err)
err = manager.Install(plugin) err = manager.Install(plugin)
@@ -137,7 +137,7 @@ func TestManagerUpdate(t *testing.T) {
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID) pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0o755) err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err) require.NoError(t, err)
pullCalled := false pullCalled := false
@@ -171,7 +171,7 @@ func TestUninstall(t *testing.T) {
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID) pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0o755) err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err) require.NoError(t, err)
err = manager.Uninstall(plugin) err = manager.Uninstall(plugin)
@@ -195,14 +195,14 @@ func TestListInstalled(t *testing.T) {
t.Run("lists installed plugins", func(t *testing.T) { t.Run("lists installed plugins", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t) manager, fs, pluginsDir := setupTestManager(t)
err := fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0o755) err := fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0o644) err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
require.NoError(t, err) require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin2"), 0o755) err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin2"), 0755)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin2", "plugin.json"), []byte(`{"id":"Plugin2"}`), 0o644) err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin2", "plugin.json"), []byte(`{"id":"Plugin2"}`), 0644)
require.NoError(t, err) require.NoError(t, err)
installed, err := manager.ListInstalled() installed, err := manager.ListInstalled()
@@ -223,15 +223,15 @@ func TestListInstalled(t *testing.T) {
t.Run("ignores files and .repos directory", func(t *testing.T) { t.Run("ignores files and .repos directory", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t) manager, fs, pluginsDir := setupTestManager(t)
err := fs.MkdirAll(pluginsDir, 0o755) err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err) require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0o755) err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0o644) err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
require.NoError(t, err) require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, ".repos"), 0o755) err = fs.MkdirAll(filepath.Join(pluginsDir, ".repos"), 0755)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("test"), 0o644) err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("test"), 0644)
require.NoError(t, err) require.NoError(t, err)
installed, err := manager.ListInstalled() installed, err := manager.ListInstalled()

View File

@@ -26,7 +26,6 @@ type Plugin struct {
Compositors []string `json:"compositors"` Compositors []string `json:"compositors"`
Distro []string `json:"distro"` Distro []string `json:"distro"`
Screenshot string `json:"screenshot,omitempty"` Screenshot string `json:"screenshot,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
} }
type GitClient interface { type GitClient interface {
@@ -147,7 +146,7 @@ func (r *Registry) Update() error {
} }
if !exists { if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
@@ -162,7 +161,7 @@ func (r *Registry) Update() error {
return fmt.Errorf("failed to remove corrupted registry: %w", err) return fmt.Errorf("failed to remove corrupted registry: %w", err)
} }
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }

View File

@@ -63,13 +63,13 @@ func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
func createTestPlugin(t *testing.T, fs afero.Fs, dir string, filename string, plugin Plugin) { func createTestPlugin(t *testing.T, fs afero.Fs, dir string, filename string, plugin Plugin) {
pluginsDir := filepath.Join(dir, "plugins") pluginsDir := filepath.Join(dir, "plugins")
err := fs.MkdirAll(pluginsDir, 0o755) err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err) require.NoError(t, err)
data, err := json.Marshal(plugin) data, err := json.Marshal(plugin)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0o644) err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0644)
require.NoError(t, err) require.NoError(t, err)
} }
@@ -118,10 +118,10 @@ func TestLoadPlugins(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t) registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins") pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0o755) err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("# Test"), 0o644) err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("# Test"), 0644)
require.NoError(t, err) require.NoError(t, err)
plugin := Plugin{ plugin := Plugin{
@@ -146,7 +146,7 @@ func TestLoadPlugins(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t) registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins") pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755) err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0755)
require.NoError(t, err) require.NoError(t, err)
plugin := Plugin{ plugin := Plugin{
@@ -170,10 +170,10 @@ func TestLoadPlugins(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t) registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins") pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0o755) err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err) require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "invalid.json"), []byte("{invalid json}"), 0o644) err = afero.WriteFile(fs, filepath.Join(pluginsDir, "invalid.json"), []byte("{invalid json}"), 0644)
require.NoError(t, err) require.NoError(t, err)
plugin := Plugin{ plugin := Plugin{
@@ -303,7 +303,7 @@ func TestUpdate(t *testing.T) {
Distro: []string{"any"}, Distro: []string{"any"},
} }
err := fs.MkdirAll(tmpDir, 0o755) err := fs.MkdirAll(tmpDir, 0755)
require.NoError(t, err) require.NoError(t, err)
pullCalled := false pullCalled := false

View File

@@ -107,7 +107,7 @@ func GetOutputDir() string {
if xdgPics := getXDGPicturesDir(); xdgPics != "" { if xdgPics := getXDGPicturesDir(); xdgPics != "" {
screenshotDir := filepath.Join(xdgPics, "Screenshots") screenshotDir := filepath.Join(xdgPics, "Screenshots")
if err := os.MkdirAll(screenshotDir, 0o755); err == nil { if err := os.MkdirAll(screenshotDir, 0755); err == nil {
return screenshotDir return screenshotDir
} }
return xdgPics return xdgPics

View File

@@ -141,7 +141,7 @@ func (r *RegionSelector) setupKeyboardHandlers() {
for _, os := range r.surfaces { for _, os := range r.surfaces {
r.redrawSurface(os) r.redrawSurface(os)
} }
case 28, 57, 96: case 28, 57:
if r.selection.hasSelection { if r.selection.hasSelection {
r.finishSelection() r.finishSelection()
} }

View File

@@ -39,7 +39,7 @@ func LoadState() (*PersistentState, error) {
func SaveState(state *PersistentState) error { func SaveState(state *PersistentState) error {
path := getStateFilePath() path := getStateFilePath()
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
} }
@@ -47,7 +47,7 @@ func SaveState(state *PersistentState) error {
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(path, data, 0o644) return os.WriteFile(path, data, 0644)
} }
func GetLastRegion() Region { func GetLastRegion() Region {

View File

@@ -19,9 +19,9 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
func handleOpen(conn net.Conn, req models.Request, manager *Manager) { func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params) log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params)
target, ok := models.Get[string](req, "target") target, ok := req.Params["target"].(string)
if !ok { if !ok {
target, ok = models.Get[string](req, "url") target, ok = req.Params["url"].(string)
if !ok { if !ok {
log.Warnf("AppPicker: Invalid target parameter in request") log.Warnf("AppPicker: Invalid target parameter in request")
models.RespondError(conn, req.ID, "invalid target parameter") models.RespondError(conn, req.ID, "invalid target parameter")
@@ -31,11 +31,14 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
event := OpenEvent{ event := OpenEvent{
Target: target, Target: target,
RequestType: models.GetOr(req, "requestType", "url"), RequestType: "url",
MimeType: models.GetOr(req, "mimeType", ""),
} }
if categories, ok := models.Get[[]any](req, "categories"); ok { if mimeType, ok := req.Params["mimeType"].(string); ok {
event.MimeType = mimeType
}
if categories, ok := req.Params["categories"].([]any); ok {
event.Categories = make([]string, 0, len(categories)) event.Categories = make([]string, 0, len(categories))
for _, cat := range categories { for _, cat := range categories {
if catStr, ok := cat.(string); ok { if catStr, ok := cat.(string); ok {
@@ -44,6 +47,10 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
} }
} }
if requestType, ok := req.Params["requestType"].(string); ok {
event.RequestType = requestType
}
log.Infof("AppPicker: Broadcasting event: %+v", event) log.Infof("AppPicker: Broadcasting event: %+v", event)
manager.RequestOpen(event) manager.RequestOpen(event)
models.Respond(conn, req.ID, "ok") models.Respond(conn, req.ID, "ok")

View File

@@ -7,7 +7,6 @@ import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -111,15 +110,17 @@ func (m *Manager) updateAdapterState() error {
if err != nil { if err != nil {
return err return err
} }
powered, _ := poweredVar.Value().(bool)
discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering") discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering")
if err != nil { if err != nil {
return err return err
} }
discovering, _ := discoveringVar.Value().(bool)
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.Powered = dbusutil.AsOr(poweredVar, false) m.state.Powered = powered
m.state.Discovering = dbusutil.AsOr(discoveringVar, false) m.state.Discovering = discovering
m.stateMutex.Unlock() m.stateMutex.Unlock()
return nil return nil
@@ -168,20 +169,65 @@ func (m *Manager) updateDevices() error {
} }
func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device { func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
return Device{ dev := Device{Path: path}
Path: path,
Address: dbusutil.GetOr(props, "Address", ""), if v, ok := props["Address"]; ok {
Name: dbusutil.GetOr(props, "Name", ""), if addr, ok := v.Value().(string); ok {
Alias: dbusutil.GetOr(props, "Alias", ""), dev.Address = addr
Paired: dbusutil.GetOr(props, "Paired", false), }
Trusted: dbusutil.GetOr(props, "Trusted", false),
Blocked: dbusutil.GetOr(props, "Blocked", false),
Connected: dbusutil.GetOr(props, "Connected", false),
Class: dbusutil.GetOr(props, "Class", uint32(0)),
Icon: dbusutil.GetOr(props, "Icon", ""),
RSSI: dbusutil.GetOr(props, "RSSI", int16(0)),
LegacyPairing: dbusutil.GetOr(props, "LegacyPairing", false),
} }
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 { func (m *Manager) startAgent() error {
@@ -282,13 +328,17 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant
m.stateMutex.Lock() m.stateMutex.Lock()
dirty := false dirty := false
if powered, ok := dbusutil.Get[bool](changed, "Powered"); ok { if v, ok := changed["Powered"]; ok {
m.state.Powered = powered if powered, ok := v.Value().(bool); ok {
dirty = true m.state.Powered = powered
dirty = true
}
} }
if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok { if v, ok := changed["Discovering"]; ok {
m.state.Discovering = discovering if discovering, ok := v.Value().(bool); ok {
dirty = true m.state.Discovering = discovering
dirty = true
}
} }
m.stateMutex.Unlock() m.stateMutex.Unlock()
@@ -299,28 +349,31 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant
} }
func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) { func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
paired, hasPaired := dbusutil.Get[bool](changed, "Paired") pairedVar, hasPaired := changed["Paired"]
_, hasConnected := changed["Connected"] _, hasConnected := changed["Connected"]
_, hasTrusted := changed["Trusted"] _, hasTrusted := changed["Trusted"]
if hasPaired { if hasPaired {
devicePath := string(path) devicePath := string(path)
if paired { if paired, ok := pairedVar.Value().(bool); ok {
_, wasPending := m.pendingPairings.LoadAndDelete(devicePath) if paired {
if wasPending { _, wasPending := m.pendingPairings.LoadAndDelete(devicePath)
select {
case m.eventQueue <- func() { if wasPending {
time.Sleep(300 * time.Millisecond) select {
log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath) case m.eventQueue <- func() {
if err := m.ConnectDevice(devicePath); err != nil { time.Sleep(300 * time.Millisecond)
log.Warnf("[Bluetooth] Auto-connect failed: %v", err) 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:
} }
}:
default:
} }
} else {
m.pendingPairings.Delete(devicePath)
} }
} else {
m.pendingPairings.Delete(devicePath)
} }
} }

View File

@@ -186,7 +186,7 @@ func (b *SysfsBackend) SetBrightnessWithExponent(id string, percent int, exponen
brightnessPath := filepath.Join(devicePath, "brightness") brightnessPath := filepath.Join(devicePath, "brightness")
data := []byte(fmt.Sprintf("%d", value)) data := []byte(fmt.Sprintf("%d", value))
if err := os.WriteFile(brightnessPath, data, 0o644); err != nil { if err := os.WriteFile(brightnessPath, data, 0644); err != nil {
return fmt.Errorf("write brightness: %w", err) return fmt.Errorf("write brightness: %w", err)
} }

View File

@@ -15,13 +15,13 @@ func TestManager_SetBrightness_LogindSuccess(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight") backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
if err := os.MkdirAll(backlightDir, 0o755); err != nil { if err := os.MkdirAll(backlightDir, 0755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -86,13 +86,13 @@ func TestManager_SetBrightness_LogindFailsFallbackToSysfs(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight") backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
if err := os.MkdirAll(backlightDir, 0o755); err != nil { if err := os.MkdirAll(backlightDir, 0755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -158,13 +158,13 @@ func TestManager_SetBrightness_NoLogind(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight") backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight")
if err := os.MkdirAll(backlightDir, 0o755); err != nil { if err := os.MkdirAll(backlightDir, 0755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -215,13 +215,13 @@ func TestManager_SetBrightness_LEDWithLogind(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
ledsDir := filepath.Join(tmpDir, "leds", "test_led") ledsDir := filepath.Join(tmpDir, "leds", "test_led")
if err := os.MkdirAll(ledsDir, 0o755); err != nil { if err := os.MkdirAll(ledsDir, 0755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }

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