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

Compare commits

..

11 Commits

Author SHA1 Message Date
LuckShiba
25322970ed doctor: use network backend detector 2026-01-03 20:14:51 -03:00
LuckShiba
6679a5f6f9 doctor: refactor to use DoctorStatus struct and 'enum' 2026-01-03 20:07:27 -03:00
LuckShiba
13c352fd58 doctor: use builtin config/cache dir functions 2026-01-03 18:53:15 -03:00
LuckShiba
002039bd3f doctor: fix icon theme env variable 2026-01-03 18:50:38 -03:00
LuckShiba
85a7961e74 doctor: cleanup code 2026-01-03 18:14:27 -03:00
LuckShiba
822e1c4404 doctor: add power-profiles-daemon and i2c 2026-01-03 18:14:27 -03:00
LuckShiba
cd5bb35be5 doctor: indicate if config files are readonly 2026-01-03 18:14:27 -03:00
LuckShiba
40b103d6d4 doctor: show useful env variables 2026-01-03 18:14:27 -03:00
LuckShiba
170082751b doctor: show compositor, quickshell and cli path in verbose 2026-01-03 18:14:27 -03:00
LuckShiba
05c7293b34 doctor: use console.warn for quickshell feature logs 2026-01-03 18:14:27 -03:00
LuckShiba
4f52e22535 feat: doctor command 2026-01-03 18:14:25 -03:00
487 changed files with 16933 additions and 83797 deletions

View File

@@ -42,12 +42,12 @@ body:
placeholder: e.g., PikaOS, Void Linux, etc. placeholder: e.g., PikaOS, Void Linux, etc.
validations: validations:
required: false required: false
- type: textarea - type: input
id: dms_doctor id: dms_version
attributes: attributes:
label: dms doctor -v label: dms version
description: Output of `dms doctor -v` command description: Output of dms version command
placeholder: Paste the output of `dms doctor -v` here placeholder: e.g., 1.2.3
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@@ -1,5 +1,5 @@
name: Feature Request 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. description: Suggest a new feature or improvement for DMS
labels: labels:
- enhancement - enhancement
body: body:

View File

@@ -27,12 +27,12 @@ body:
placeholder: Your Linux distribution placeholder: Your Linux distribution
validations: validations:
required: false required: false
- type: textarea - type: input
id: dms_doctor id: dms_version
attributes: attributes:
label: dms doctor -v label: dms version
description: Output of `dms doctor -v` command description: Output of dms version command
placeholder: Paste the output of `dms doctor -v` here placeholder: e.g., 1.2.3
validations: validations:
required: false required: false
- type: textarea - type: textarea

View File

@@ -1,31 +0,0 @@
name: Update stable branch
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
update-stable:
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
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:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Push to stable branch
env:
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

View File

@@ -20,10 +20,5 @@ jobs:
- name: Add a flatpak that mutagen could support - name: Add a flatpak that mutagen could support
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen 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,20 +4,19 @@ 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
default: "" default: ""
schedule: schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - cron: "0 */3 * * *" # Every 3 hours for dms-git builds
jobs: jobs:
check-updates: check-updates:
@@ -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
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Auto-detected latest release: $LATEST_TAG"
else
echo "ERROR: Could not auto-detect latest release"
exit 1 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

@@ -12,7 +12,7 @@ on:
required: false required: false
default: "" default: ""
schedule: schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - cron: "0 */3 * * *" # Every 3 hours for dms-git builds
jobs: jobs:
check-updates: check-updates:

19
.github/workflows/stable.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Update stable branch
on:
push:
tags:
- "v*"
jobs:
update-stable:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to stable branch
run: git push origin 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,15 +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
- window rule manager, with IPC - #TODO verify RTL layout (niri only)
# 1.2.0 # 1.2.0
- Added clipboard and clipboard history integration - Added clipboard and clipboard history integration
@@ -26,8 +16,3 @@ This file is more of a quick reference so I know what to account for before next
- Initial RTL support/i18n - Initial RTL support/i18n
- Theme registry - Theme registry
- Notification persistence & history - 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:
@@ -58,10 +59,10 @@ install-completions:
install-systemd: install-systemd:
@echo "Installing systemd user service..." @echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR) @mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi @if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR); fi
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service @sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service @chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi @if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):$(SUDO_USER) $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service" @echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
install-icon: install-icon:

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

@@ -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
) )
@@ -161,40 +144,15 @@ var (
clipConfigEnabled bool clipConfigEnabled 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")
@@ -212,19 +170,16 @@ func init() {
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,68 +187,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)
}
fmt.Printf("Downloaded and copied: %s\n", filePath)
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 {
@@ -450,13 +348,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)
} }
@@ -705,264 +606,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}
var data []byte
var contentType string
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
lastErr = fmt.Errorf("create request: %w", err)
continue
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "image/*,video/*,*/*")
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf("download (attempt %d): %w", attempt+1, err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
lastErr = fmt.Errorf("download failed (attempt %d): status %d", attempt+1, resp.StatusCode)
continue
}
data, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("read response (attempt %d): %w", attempt+1, err)
continue
}
contentType = resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx != -1 {
contentType = strings.TrimSpace(contentType[:idx])
}
lastErr = nil
break
}
if lastErr != nil {
return "", lastErr
}
if len(data) == 0 {
return "", fmt.Errorf("downloaded empty file")
}
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"
@@ -87,8 +86,6 @@ var (
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`) swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`) riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
wayfireVersionRegex = regexp.MustCompile(`wayfire (\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{
@@ -98,14 +95,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
@@ -149,7 +142,6 @@ func (c category) String() string {
const ( const (
checkNameMaxLength = 21 checkNameMaxLength = 21
doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
) )
type checkResult struct { type checkResult struct {
@@ -158,43 +150,10 @@ type checkResult struct {
status status status status
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()
@@ -210,13 +169,9 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkEnvironmentVars(), checkEnvironmentVars(),
) )
if doctorJSON {
printResultsJSON(results)
} else {
printResults(results) printResults(results)
printSummary(results, qsMissingFeatures) printSummary(results, qsMissingFeatures)
} }
}
func printDoctorHeader() { func printDoctorHeader() {
theme := tui.TerminalTheme() theme := tui.TerminalTheme()
@@ -237,21 +192,20 @@ func checkSystemInfo() []checkResult {
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 = statusOK 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 := statusOK
message := osInfo.PrettyName message := osInfo.PrettyName
@@ -265,7 +219,6 @@ func checkSystemInfo() []checkResult {
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",
}) })
} }
@@ -274,7 +227,7 @@ func checkSystemInfo() []checkResult {
if arch != "amd64" && arch != "arm64" { if arch != "amd64" && arch != "arm64" {
archStatus = statusError archStatus = statusError
} }
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")
@@ -284,15 +237,13 @@ func checkSystemInfo() []checkResult {
results = append(results, checkResult{ results = append(results, checkResult{
catSystem, "Display Server", statusOK, "Wayland", catSystem, "Display Server", statusOK, "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", statusError, "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", statusWarn, "Unknown (ensure you're running Wayland)",
fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType), fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType),
doctorDocsURL + "#display-server",
}) })
} }
@@ -309,10 +260,9 @@ func checkEnvironmentVars() []checkResult {
func checkEnvVar(name string) []checkResult { func checkEnvVar(name string) []checkResult {
value := os.Getenv(name) value := os.Getenv(name)
if value != "" { if value != "" {
return []checkResult{{catEnvironment, name, statusInfo, value, "", doctorDocsURL + "#environment-variables"}} return []checkResult{{catEnvironment, name, statusInfo, value, ""}}
} } else if doctorVerbose {
if doctorVerbose { return []checkResult{{catEnvironment, name, statusInfo, "Not set", ""}}
return []checkResult{{catEnvironment, name, statusInfo, "Not set", "", doctorDocsURL + "#environment-variables"}}
} }
return nil return nil
} }
@@ -339,7 +289,7 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
} }
results := []checkResult{ results := []checkResult{
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"}, {catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails},
} }
qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures) qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures)
@@ -347,13 +297,13 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
if doctorVerbose && qsPath != "" { if doctorVerbose && qsPath != "" {
qsDetails = qsPath qsDetails = qsPath
} }
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails, doctorDocsURL + "#quickshell"}) results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails})
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", statusOK, 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", statusError, "Not installed or not detected", "Run 'dms setup' to install"})
} }
return results return results
@@ -416,16 +366,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", statusError, "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", statusOK, "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", statusError, "Missing", shellQml})
} else { } else {
results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml, doctorDocsURL + "#dms-configuration"}) results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml})
} }
if doctorVerbose { if doctorVerbose {
@@ -438,7 +388,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", statusInfo, installType, dmsPath})
} }
return results return results
@@ -450,22 +400,18 @@ func checkWindowManagers() []checkResult {
versionRegex *regexp.Regexp versionRegex *regexp.Regexp
commands []string commands []string
}{ }{
{"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}}, {"Hyprland", "hyprctl", "version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}}, {"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}}, {"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
{"River", "river", "-version", riverVersionRegex, []string{"river"}}, {"River", "river", "-version", riverVersionRegex, []string{"river"}},
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}}, {"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
} }
var results []checkResult var 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 foundAny = true
var compositorPath string var compositorPath string
for _, cmd := range c.commands { for _, cmd := range c.commands {
@@ -481,29 +427,28 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{ results = append(results, checkResult{
catCompositor, c.name, statusOK, catCompositor, c.name, statusOK,
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details, 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", statusError,
"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", statusInfo, wm, ""})
} }
return results return results
} }
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string { func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput() output, err := exec.Command(cmd, arg).Output()
if err != nil && len(output) == 0 { if err != nil {
return "installed" return "installed"
} }
@@ -591,7 +536,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
} }
@@ -617,7 +562,7 @@ ShellRoot {
status, message = statusInfo, "Not available" status, message = statusInfo, "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
@@ -626,26 +571,31 @@ ShellRoot {
func checkI2CAvailability() checkResult { func checkI2CAvailability() checkResult {
ddc, err := brightness.NewDDCBackend() ddc, err := brightness.NewDDCBackend()
if err != nil { if err != nil {
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control", doctorDocsURL + "#optional-features"} return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control"}
} }
defer ddc.Close() defer ddc.Close()
devices, err := ddc.GetDevices() devices, err := ddc.GetDevices()
if err != nil || len(devices) == 0 { 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", statusInfo, "No monitors detected", "External monitor brightness control"}
} }
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "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"}
} }
func detectNetworkBackend(stackResult *network.DetectResult) string { func detectNetworkBackend() string {
switch stackResult.Backend { result, err := network.DetectNetworkStack()
if err != nil {
return ""
}
switch result.Backend {
case network.BackendNetworkManager: case network.BackendNetworkManager:
return "NetworkManager" return "NetworkManager"
case network.BackendIwd: case network.BackendIwd:
return "iwd" return "iwd"
case network.BackendNetworkd: case network.BackendNetworkd:
if stackResult.HasIwd { if result.HasIwd {
return "iwd + systemd-networkd" return "iwd + systemd-networkd"
} }
return "systemd-networkd" return "systemd-networkd"
@@ -656,73 +606,77 @@ func detectNetworkBackend(stackResult *network.DetectResult) string {
} }
} }
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 var results []checkResult
optionalFeaturesURL := doctorDocsURL + "#optional-features" if utils.IsServiceActive("accounts-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusOK, "Running", "User accounts"})
} else {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusWarn, "Not running", "User accounts"})
}
accountsStatus, accountsMsg := getOptionalDBusStatus("org.freedesktop.Accounts") if utils.IsServiceActive("power-profiles-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", accountsStatus, accountsMsg, "User accounts", optionalFeaturesURL}) results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusOK, "Running", "Power profile management"})
} else {
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusInfo, "Not running", "Power profile management"})
}
ppdStatus, ppdMsg := getOptionalDBusStatus("org.freedesktop.UPower.PowerProfiles") i2cStatus := checkI2CAvailability()
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", ppdStatus, ppdMsg, "Power profile management", optionalFeaturesURL}) results = append(results, i2cStatus)
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 { if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL}) results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], ""})
} else { } else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL}) results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty"})
} }
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
}
results = append(results, checkResult{catOptionalFeatures, "Network", networkStatus, networkMessage, networkDetails, optionalFeaturesURL})
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}) details := d.desc
case d.important: if d.name == "Network" {
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, optionalFeaturesURL}) result, err := network.DetectNetworkStack()
default: if err == nil && result.Backend != network.BackendNone {
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, optionalFeaturesURL}) message = detectNetworkBackend() + " (active)"
if doctorVerbose {
details = result.ChosenReason
}
} else {
switch foundCmd {
case "nmcli":
message = "NetworkManager (installed)"
case "iwctl":
message = "iwd (installed)"
}
}
}
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, message, details})
} else if d.important {
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc})
} else {
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc})
} }
} }
@@ -745,18 +699,19 @@ func checkConfigurationFiles() []checkResult {
var results []checkResult var results []checkResult
for _, cf := range configFiles { for _, cf := range configFiles {
info, err := os.Stat(cf.path) info, err := os.Stat(cf.path)
if err != nil { if err == nil {
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path, doctorDocsURL + "#config-files"})
continue
}
status := statusOK status := statusOK
message := "Present" message := "Present"
if info.Mode().Perm()&0o200 == 0 {
if info.Mode().Perm()&0200 == 0 {
status = statusWarn status = statusWarn
message += " (read-only)" message += " (read-only)"
} }
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path, doctorDocsURL + "#config-files"})
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path})
} else {
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path})
}
} }
return results return results
} }
@@ -770,31 +725,27 @@ func checkSystemdServices() []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", statusInfo, "Not installed", "Optional user service"})
} else { } else {
status, message := statusOK, dmsState.enabled status, message := statusOK, 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 = statusWarn, "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 := statusOK status := statusOK
if greetdState.enabled == "disabled" { if greetdState.enabled == "disabled" {
status = statusInfo status = statusInfo
} }
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", statusInfo, "Not installed", "Optional greeter service"})
} }
return results return results
@@ -848,31 +799,6 @@ func printResults(results []checkResult) {
} }
} }
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 := r.status.IconStyle(styles)
@@ -890,10 +816,6 @@ func printResultLine(r checkResult, styles tui.Styles) {
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) {

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")
@@ -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 {
@@ -72,3 +76,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

@@ -1,338 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers"
"github.com/spf13/cobra"
)
var windowrulesCmd = &cobra.Command{
Use: "windowrules",
Short: "Manage window rules",
}
var windowrulesListCmd = &cobra.Command{
Use: "list [compositor]",
Short: "List all window rules",
Long: "List all window rules from compositor config file. Returns JSON with rules and DMS status.",
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runWindowrulesList,
}
var windowrulesAddCmd = &cobra.Command{
Use: "add <compositor> '<json>'",
Short: "Add a window rule to DMS file",
Long: "Add a new window rule to the DMS-managed rules file.",
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runWindowrulesAdd,
}
var windowrulesUpdateCmd = &cobra.Command{
Use: "update <compositor> <id> '<json>'",
Short: "Update a window rule in DMS file",
Long: "Update an existing window rule in the DMS-managed rules file.",
Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runWindowrulesUpdate,
}
var windowrulesRemoveCmd = &cobra.Command{
Use: "remove <compositor> <id>",
Short: "Remove a window rule from DMS file",
Long: "Remove a window rule from the DMS-managed rules file.",
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runWindowrulesRemove,
}
var windowrulesReorderCmd = &cobra.Command{
Use: "reorder <compositor> '<json-array-of-ids>'",
Short: "Reorder window rules in DMS file",
Long: "Reorder window rules by providing a JSON array of rule IDs in the desired order.",
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runWindowrulesReorder,
}
func init() {
configCmd.AddCommand(windowrulesCmd)
windowrulesCmd.AddCommand(windowrulesListCmd)
windowrulesCmd.AddCommand(windowrulesAddCmd)
windowrulesCmd.AddCommand(windowrulesUpdateCmd)
windowrulesCmd.AddCommand(windowrulesRemoveCmd)
windowrulesCmd.AddCommand(windowrulesReorderCmd)
}
type WindowRulesListResult struct {
Rules []windowrules.WindowRule `json:"rules"`
DMSStatus *windowrules.DMSRulesStatus `json:"dmsStatus,omitempty"`
}
type WindowRuleWriteResult struct {
Success bool `json:"success"`
ID string `json:"id,omitempty"`
Path string `json:"path,omitempty"`
Error string `json:"error,omitempty"`
}
func getCompositor(args []string) string {
if len(args) > 0 {
return strings.ToLower(args[0])
}
if os.Getenv("NIRI_SOCKET") != "" {
return "niri"
}
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
// return "hyprland"
// }
return ""
}
func writeRuleError(errMsg string) {
result := WindowRuleWriteResult{Success: false, Error: errMsg}
output, _ := json.Marshal(result)
fmt.Fprintln(os.Stdout, string(output))
os.Exit(1)
}
func writeRuleSuccess(id, path string) {
result := WindowRuleWriteResult{Success: true, ID: id, Path: path}
output, _ := json.Marshal(result)
fmt.Fprintln(os.Stdout, string(output))
}
func runWindowrulesList(cmd *cobra.Command, args []string) {
compositor := getCompositor(args)
if compositor == "" {
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri")
}
var result WindowRulesListResult
switch compositor {
case "niri":
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
log.Fatalf("Failed to expand niri config path: %v", err)
}
parseResult, err := providers.ParseNiriWindowRules(configDir)
if err != nil {
log.Fatalf("Failed to parse niri window rules: %v", err)
}
allRules := providers.ConvertNiriRulesToWindowRules(parseResult.Rules)
provider := providers.NewNiriWritableProvider(configDir)
dmsRulesPath := provider.GetOverridePath()
dmsRules, _ := provider.LoadDMSRules()
dmsRuleMap := make(map[int]windowrules.WindowRule)
for i, dr := range dmsRules {
dmsRuleMap[i] = dr
}
dmsIdx := 0
for i, r := range allRules {
if r.Source == dmsRulesPath {
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
allRules[i].ID = dmr.ID
allRules[i].Name = dmr.Name
}
dmsIdx++
}
}
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
case "hyprland":
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err)
}
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
if err != nil {
log.Fatalf("Failed to parse hyprland window rules: %v", err)
}
allRules := providers.ConvertHyprlandRulesToWindowRules(parseResult.Rules)
provider := providers.NewHyprlandWritableProvider(configDir)
dmsRulesPath := provider.GetOverridePath()
dmsRules, _ := provider.LoadDMSRules()
dmsRuleMap := make(map[int]windowrules.WindowRule)
for i, dr := range dmsRules {
dmsRuleMap[i] = dr
}
dmsIdx := 0
for i, r := range allRules {
if r.Source == dmsRulesPath {
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
allRules[i].ID = dmr.ID
allRules[i].Name = dmr.Name
}
dmsIdx++
}
}
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
default:
log.Fatalf("Unknown compositor: %s", compositor)
}
output, _ := json.Marshal(result)
fmt.Fprintln(os.Stdout, string(output))
}
func runWindowrulesAdd(cmd *cobra.Command, args []string) {
compositor := strings.ToLower(args[0])
ruleJSON := args[1]
var rule windowrules.WindowRule
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
}
if rule.ID == "" {
rule.ID = generateRuleID()
}
rule.Enabled = true
provider := getWindowRulesProvider(compositor)
if provider == nil {
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
}
if err := provider.SetRule(rule); err != nil {
writeRuleError(err.Error())
}
writeRuleSuccess(rule.ID, provider.GetOverridePath())
}
func runWindowrulesUpdate(cmd *cobra.Command, args []string) {
compositor := strings.ToLower(args[0])
ruleID := args[1]
ruleJSON := args[2]
var rule windowrules.WindowRule
if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil {
writeRuleError(fmt.Sprintf("Invalid JSON: %v", err))
}
rule.ID = ruleID
provider := getWindowRulesProvider(compositor)
if provider == nil {
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
}
if err := provider.SetRule(rule); err != nil {
writeRuleError(err.Error())
}
writeRuleSuccess(rule.ID, provider.GetOverridePath())
}
func runWindowrulesRemove(cmd *cobra.Command, args []string) {
compositor := strings.ToLower(args[0])
ruleID := args[1]
provider := getWindowRulesProvider(compositor)
if provider == nil {
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
}
if err := provider.RemoveRule(ruleID); err != nil {
writeRuleError(err.Error())
}
writeRuleSuccess(ruleID, provider.GetOverridePath())
}
func runWindowrulesReorder(cmd *cobra.Command, args []string) {
compositor := strings.ToLower(args[0])
idsJSON := args[1]
var ids []string
if err := json.Unmarshal([]byte(idsJSON), &ids); err != nil {
writeRuleError(fmt.Sprintf("Invalid JSON array: %v", err))
}
provider := getWindowRulesProvider(compositor)
if provider == nil {
writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor))
}
if err := provider.ReorderRules(ids); err != nil {
writeRuleError(err.Error())
}
writeRuleSuccess("", provider.GetOverridePath())
}
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
switch compositor {
case "niri":
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return nil
}
return providers.NewNiriWritableProvider(configDir)
case "hyprland":
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return nil
}
return providers.NewHyprlandWritableProvider(configDir)
default:
return nil
}
}
func generateRuleID() string {
return fmt.Sprintf("wr_%d", time.Now().UnixNano())
}

View File

@@ -618,8 +618,9 @@ 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" {
@@ -641,45 +642,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

@@ -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 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 filepath.Join(dbDir, "db"), nil
return "", err
}
return newPath, nil
} }
func deduplicateInTx(b *bolt.Bucket, hash uint64) error { func deduplicateInTx(b *bolt.Bucket, hash uint64) error {

View File

@@ -2,7 +2,6 @@ package clipboard
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -131,29 +130,13 @@ func Watch(ctx context.Context, callback func(data []byte, mimeType string)) err
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
return fmt.Errorf("set read deadline: %w", err) return fmt.Errorf("set read deadline: %w", err)
} }
if err := wlCtx.Dispatch(); err != nil { if err := wlCtx.Dispatch(); err != nil && err != os.ErrDeadlineExceeded {
if isTimeoutError(err) {
continue
}
return fmt.Errorf("dispatch: %w", err) return fmt.Errorf("dispatch: %w", err)
} }
} }
} }
} }
func isTimeoutError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrDeadlineExceeded) {
return true
}
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
return true
}
return false
}
func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) { func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) {
ch := make(chan ClipboardChange, 16) ch := make(chan ClipboardChange, 16)
errCh := make(chan error, 1) errCh := make(chan error, 1)

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,19 +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", ""},
{"windowrules.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))
@@ -239,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
} }
@@ -255,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
} }
@@ -277,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
} }
@@ -303,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
} }
@@ -319,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
} }
@@ -340,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
} }
@@ -354,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
} }
@@ -375,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
} }
@@ -391,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
} }
@@ -412,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
} }
@@ -424,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)
@@ -456,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
@@ -480,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")
@@ -504,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
} }
@@ -530,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 {
@@ -539,51 +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", ""},
{"windowrules.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)
@@ -591,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, "windowrule = border_size 0, match: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

@@ -1,160 +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
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === 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,6 +84,7 @@ master {
misc { misc {
disable_hyprland_logo = true disable_hyprland_logo = true
disable_splash_rendering = true disable_splash_rendering = true
vrr = 1
} }
# ================== # ==================
@@ -89,6 +93,7 @@ misc {
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$ windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = rounding 12, match:class ^(org\.gnome\.) windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrule = border_size 0, match:class ^(org\.gnome\.)
windowrule = tile on, match:class ^(gnome-control-center)$ windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$ windowrule = tile on, match:class ^(pavucontrol)$
@@ -101,17 +106,178 @@ windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(steam)$ windowrule = float on, match:class ^(steam)$
windowrule = float on, match:class ^(xdg-desktop-portal)$ windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrule = border_size 0, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = border_size 0, match:class ^(Alacritty)$
windowrule = border_size 0, match:class ^(zen)$
windowrule = border_size 0, match:class ^(com\.mitchellh\.ghostty)$
windowrule = border_size 0, match:class ^(kitty)$
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$ windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$ windowrule = float on, match:class ^(zoom)$
# DMS windows floating by default # DMS windows floating by default
# ! Hyprland doesn't size these windows correctly so disabling by default here windowrule = float on, match:class ^(org.quickshell)$
# windowrule = float on, match:class ^(org.quickshell)$ windowrule = opacity 0.9 0.9, match:float false, match:focus false
layerrule = no_anim on, match:namespace ^(quickshell)$ layerrule = no_anim on, match:namespace ^(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
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 = $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";
} }
@@ -76,7 +79,6 @@ binds {
Mod+Shift+T { toggle-window-floating; } Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; } Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; } Mod+W { toggle-column-tabbed-display; }
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
// === Focus Navigation === // === Focus Navigation ===
Mod+Left { focus-column-left; } Mod+Left { focus-column-left; }
@@ -134,11 +136,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)
@@ -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 {

View File

@@ -41,9 +41,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 {

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

@@ -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

@@ -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 { func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
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 {
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 {
@@ -36,34 +34,12 @@ 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,31 +7,36 @@ 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
}{
{
name: "custom path",
configPath: "/custom/path",
wantPath: "/custom/path",
},
{
name: "empty path defaults",
configPath: "",
wantPath: "$HOME/.config/hypr",
},
} }
if p.configPath != "/custom/path" {
t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path")
}
})
t.Run("empty path defaults", func(t *testing.T) { for _, tt := range tests {
p := NewHyprlandProvider("") t.Run(tt.name, func(t *testing.T) {
p := NewHyprlandProvider(tt.configPath)
if p == nil { if p == nil {
t.Fatal("NewHyprlandProvider returned nil") t.Fatal("NewHyprlandProvider returned nil")
} }
configDir, err := os.UserConfigDir()
if err != nil { if p.configPath != tt.wantPath {
t.Fatalf("UserConfigDir failed: %v", err) 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) {
p := NewHyprlandProvider("") p := NewHyprlandProvider("")
@@ -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

@@ -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

@@ -23,48 +23,6 @@ const (
ColorModeLight ColorMode = "light" 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 { func (c *ColorMode) GTKTheme() string {
switch *c { switch *c {
case ColorModeDark: case ColorModeDark:
@@ -93,7 +51,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 {
@@ -144,11 +101,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)
} }
@@ -282,7 +236,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,32 +247,73 @@ output_path = '%s'
`, opts.ShellDir, opts.ColorsOutput()) `, opts.ShellDir, opts.ColorsOutput())
homeDir, _ := os.UserHomeDir() if !opts.ShouldSkipTemplate("gtk") {
for _, tmpl := range templateRegistry {
if opts.ShouldSkipTemplate(tmpl.ID) {
continue
}
switch tmpl.Kind {
case TemplateKindGTK:
switch opts.Mode { switch opts.Mode {
case ColorModeLight: case "light":
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml") appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
default: default:
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml") 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:
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
} }
if !opts.ShouldSkipTemplate("niri") {
appendConfig(opts, cfgFile, []string{"niri"}, nil, "niri.toml")
}
if !opts.ShouldSkipTemplate("qt5ct") {
appendConfig(opts, cfgFile, []string{"qt5ct"}, nil, "qt5ct.toml")
}
if !opts.ShouldSkipTemplate("qt6ct") {
appendConfig(opts, cfgFile, []string{"qt6ct"}, nil, "qt6ct.toml")
}
if !opts.ShouldSkipTemplate("firefox") {
appendConfig(opts, cfgFile, []string{"firefox"}, nil, "firefox.toml")
}
if !opts.ShouldSkipTemplate("pywalfox") {
appendConfig(opts, cfgFile, []string{"pywalfox"}, nil, "pywalfox.toml")
}
if !opts.ShouldSkipTemplate("zenbrowser") {
appendConfig(opts, cfgFile, []string{"zen", "zen-browser"}, []string{"app.zen_browser.zen"}, "zenbrowser.toml")
}
if !opts.ShouldSkipTemplate("vesktop") {
appendConfig(opts, cfgFile, []string{"vesktop"}, []string{"dev.vencord.Vesktop"}, "vesktop.toml")
}
if !opts.ShouldSkipTemplate("equibop") {
appendConfig(opts, cfgFile, []string{"equibop"}, nil, "equibop.toml")
}
if !opts.ShouldSkipTemplate("ghostty") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"ghostty"}, nil, "ghostty.toml")
}
if !opts.ShouldSkipTemplate("kitty") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"kitty"}, nil, "kitty.toml")
}
if !opts.ShouldSkipTemplate("foot") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"foot"}, nil, "foot.toml")
}
if !opts.ShouldSkipTemplate("alacritty") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"alacritty"}, nil, "alacritty.toml")
}
if !opts.ShouldSkipTemplate("wezterm") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"wezterm"}, nil, "wezterm.toml")
}
if !opts.ShouldSkipTemplate("nvim") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"nvim"}, nil, "neovim.toml")
}
if !opts.ShouldSkipTemplate("dgop") {
appendConfig(opts, cfgFile, []string{"dgop"}, nil, "dgop.toml")
}
if !opts.ShouldSkipTemplate("kcolorscheme") {
appendConfig(opts, cfgFile, nil, nil, "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 {
@@ -358,14 +353,17 @@ func appendConfig(
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
return return
} }
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { cmdExists := checkCmd == nil || utils.AnyCommandExists(checkCmd...)
flatpakExists := checkFlatpaks == nil || utils.AnyFlatpakExists(checkFlatpaks...)
if !cmdExists && !flatpakExists {
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")
} }
@@ -374,7 +372,10 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
return return
} }
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { cmdExists := checkCmd == nil || utils.AnyCommandExists(checkCmd...)
flatpakExists := checkFlatpaks == nil || utils.AnyFlatpakExists(checkFlatpaks...)
if !cmdExists && !flatpakExists {
return return
} }
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
@@ -385,7 +386,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 +415,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 +424,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 +451,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 {
@@ -683,52 +662,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

@@ -4,10 +4,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "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) { func TestAppendConfigBinaryExists(t *testing.T) {
@@ -15,13 +11,13 @@ func TestAppendConfigBinaryExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -32,10 +28,7 @@ func TestAppendConfigBinaryExists(t *testing.T) {
} }
defer cfgFile.Close() defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t) opts := &Options{ShellDir: shellDir}
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml") appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
@@ -58,13 +51,13 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -75,11 +68,7 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
} }
defer cfgFile.Close() defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t) opts := &Options{ShellDir: shellDir}
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") appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
@@ -99,13 +88,13 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "zen config content" testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -116,10 +105,7 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
} }
defer cfgFile.Close() defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t) opts := &Options{ShellDir: shellDir}
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") appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
@@ -139,13 +125,13 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -156,11 +142,7 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
} }
defer cfgFile.Close() defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t) opts := &Options{ShellDir: shellDir}
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") appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
@@ -180,13 +162,13 @@ func TestAppendConfigBothExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "zen config content" testConfig := "zen config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -197,10 +179,7 @@ func TestAppendConfigBothExist(t *testing.T) {
} }
defer cfgFile.Close() defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t) opts := &Options{ShellDir: shellDir}
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml") appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
@@ -220,13 +199,13 @@ func TestAppendConfigNeitherExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "test config content" testConfig := "test config content"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -237,11 +216,7 @@ func TestAppendConfigNeitherExists(t *testing.T) {
} }
defer cfgFile.Close() defer cfgFile.Close()
mockChecker := mocks_utils.NewMockAppChecker(t) opts := &Options{ShellDir: shellDir}
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") appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
@@ -261,13 +236,13 @@ func TestAppendConfigNoChecks(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
testConfig := "always include" testConfig := "always include"
configPath := filepath.Join(configsDir, "test.toml") configPath := filepath.Join(configsDir, "test.toml")
if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
@@ -298,7 +273,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell") shellDir := filepath.Join(tempDir, "shell")
configsDir := filepath.Join(shellDir, "matugen", "configs") configsDir := filepath.Join(shellDir, "matugen", "configs")
if err := os.MkdirAll(configsDir, 0o755); err != nil { if err := os.MkdirAll(configsDir, 0755); err != nil {
t.Fatalf("failed to create configs dir: %v", err) t.Fatalf("failed to create configs dir: %v", err)
} }
@@ -323,72 +298,3 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
t.Errorf("expected no config when file doesn't exist, got: %q", string(output)) 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

@@ -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

@@ -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,21 +169,66 @@ 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 {
if m.promptBroker == nil { if m.promptBroker == nil {
@@ -282,14 +328,18 @@ 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 {
if powered, ok := v.Value().(bool); ok {
m.state.Powered = powered m.state.Powered = powered
dirty = true dirty = true
} }
if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok { }
if v, ok := changed["Discovering"]; ok {
if discovering, ok := v.Value().(bool); ok {
m.state.Discovering = discovering m.state.Discovering = discovering
dirty = true dirty = true
} }
}
m.stateMutex.Unlock() m.stateMutex.Unlock()
@@ -299,14 +349,16 @@ 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, ok := pairedVar.Value().(bool); ok {
if paired { if paired {
_, wasPending := m.pendingPairings.LoadAndDelete(devicePath) _, wasPending := m.pendingPairings.LoadAndDelete(devicePath)
if wasPending { if wasPending {
select { select {
case m.eventQueue <- func() { case m.eventQueue <- func() {
@@ -323,6 +375,7 @@ func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed ma
m.pendingPairings.Delete(devicePath) m.pendingPairings.Delete(devicePath)
} }
} }
}
if hasPaired || hasConnected || hasTrusted { if hasPaired || hasConnected || hasTrusted {
select { select {

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)
} }

View File

@@ -136,26 +136,26 @@ func TestSysfsBackend_ScanDevices(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)
} }
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)
} }

View File

@@ -13,13 +13,13 @@ func setupTestManager(t *testing.T) (*Manager, string) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
backlightDir := filepath.Join(tmpDir, "backlight", "intel_backlight") backlightDir := filepath.Join(tmpDir, "backlight", "intel_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("1000\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("1000\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("500\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("500\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -152,7 +152,7 @@ func TestHandleEvent_ChangeAction(t *testing.T) {
um := &UdevMonitor{stop: make(chan struct{})} um := &UdevMonitor{stop: make(chan struct{})}
brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness") brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness")
if err := os.WriteFile(brightnessPath, []byte("800\n"), 0o644); err != nil { if err := os.WriteFile(brightnessPath, []byte("800\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -225,7 +225,7 @@ func TestHandleChange_InvalidBrightnessValue(t *testing.T) {
um := &UdevMonitor{stop: make(chan struct{})} um := &UdevMonitor{stop: make(chan struct{})}
brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness") brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness")
if err := os.WriteFile(brightnessPath, []byte("not_a_number\n"), 0o644); err != nil { if err := os.WriteFile(brightnessPath, []byte("not_a_number\n"), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -37,16 +37,6 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
handleSetConfig(conn, req, m) handleSetConfig(conn, req, m)
case "clipboard.store": case "clipboard.store":
handleStore(conn, req, m) handleStore(conn, req, m)
case "clipboard.pinEntry":
handlePinEntry(conn, req, m)
case "clipboard.unpinEntry":
handleUnpinEntry(conn, req, m)
case "clipboard.getPinnedEntries":
handleGetPinnedEntries(conn, req, m)
case "clipboard.getPinnedCount":
handleGetPinnedCount(conn, req, m)
case "clipboard.copyFile":
handleCopyFile(conn, req, m)
default: default:
models.RespondError(conn, req.ID, "unknown method: "+req.Method) models.RespondError(conn, req.ID, "unknown method: "+req.Method)
} }
@@ -128,29 +118,11 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
return return
} }
filePath := m.EntryToFile(entry)
if filePath != "" {
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, map[string]any{
"success": true,
"filePath": filePath,
})
return
}
if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil { if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
return return
} }
if err := m.TouchEntry(uint64(id)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
} }
@@ -233,9 +205,6 @@ func handleSetConfig(conn net.Conn, req models.Request, m *Manager) {
if v, ok := models.Get[bool](req, "disabled"); ok { if v, ok := models.Get[bool](req, "disabled"); ok {
cfg.Disabled = v cfg.Disabled = v
} }
if v, ok := models.Get[float64](req, "maxPinned"); ok {
cfg.MaxPinned = int(v)
}
if err := m.SetConfig(cfg); err != nil { if err := m.SetConfig(cfg); err != nil {
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
@@ -261,58 +230,3 @@ func handleStore(conn net.Conn, req models.Request, m *Manager) {
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"})
} }
func handlePinEntry(conn net.Conn, req models.Request, m *Manager) {
id, err := params.Int(req.Params, "id")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.PinEntry(uint64(id)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry pinned"})
}
func handleUnpinEntry(conn net.Conn, req models.Request, m *Manager) {
id, err := params.Int(req.Params, "id")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.UnpinEntry(uint64(id)); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry unpinned"})
}
func handleGetPinnedEntries(conn net.Conn, req models.Request, m *Manager) {
pinned := m.GetPinnedEntries()
models.Respond(conn, req.ID, pinned)
}
func handleGetPinnedCount(conn net.Conn, req models.Request, m *Manager) {
count := m.GetPinnedCount()
models.Respond(conn, req.ID, map[string]int{"count": count})
}
func handleCopyFile(conn net.Conn, req models.Request, m *Manager) {
filePath, err := params.String(req.Params, "filePath")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.CopyFile(filePath); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
}

View File

@@ -10,7 +10,6 @@ import (
_ "image/png" _ "image/png"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -20,28 +19,25 @@ import (
"hash/fnv" "hash/fnv"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/godbus/dbus/v5"
_ "golang.org/x/image/bmp" _ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff" _ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
clipboardstore "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/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
// These mime types won't be stored in history // These mime types wont be stored in history
var sensitiveMimeTypes = []string{ var sensitiveMimeTypes = []string{
"x-kde-passwordManagerHint", "x-kde-passwordManagerHint",
} }
func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) { func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) {
display := wlCtx.Display() display := wlCtx.Display()
dbPath, err := clipboardstore.GetDBPath() dbPath, err := getDBPath()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get db path: %w", err) return nil, fmt.Errorf("failed to get db path: %w", err)
} }
@@ -106,8 +102,26 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
return m, nil return m, nil
} }
func getDBPath() (string, error) {
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
cacheDir = filepath.Join(homeDir, ".cache")
}
dbDir := filepath.Join(cacheDir, "dms-clipboard")
if err := os.MkdirAll(dbDir, 0700); err != nil {
return "", err
}
return filepath.Join(dbDir, "db"), nil
}
func openDB(path string) (*bolt.DB, error) { func openDB(path string) (*bolt.DB, error) {
db, err := bolt.Open(path, 0o644, &bolt.Options{ db, err := bolt.Open(path, 0644, &bolt.Options{
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
}) })
if err != nil { if err != nil {
@@ -319,13 +333,6 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) {
} }
func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
if mimeType == "text/uri-list" {
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
data = imgData
mimeType = imgMime
}
}
entry := Entry{ entry := Entry{
Data: data, Data: data,
MimeType: mimeType, MimeType: mimeType,
@@ -337,8 +344,6 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
switch { switch {
case entry.IsImage: case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType) entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default: default:
entry.Preview = m.textPreview(data) entry.Preview = m.textPreview(data)
} }
@@ -401,11 +406,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
} }
c := b.Cursor() c := b.Cursor()
var count int var count int
for k, v := c.Last(); k != nil; k, v = c.Prev() { for k, _ := c.Last(); k != nil; k, _ = c.Prev() {
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
continue
}
if count < m.config.MaxHistory { if count < m.config.MaxHistory {
count++ count++
continue continue
@@ -435,11 +436,6 @@ func encodeEntry(e Entry) ([]byte, error) {
buf.WriteByte(0) buf.WriteByte(0)
} }
binary.Write(buf, binary.BigEndian, e.Hash) binary.Write(buf, binary.BigEndian, e.Hash)
if e.Pinned {
buf.WriteByte(1)
} else {
buf.WriteByte(0)
}
return buf.Bytes(), nil return buf.Bytes(), nil
} }
@@ -483,12 +479,6 @@ func decodeEntry(data []byte) (Entry, error) {
binary.Read(buf, binary.BigEndian, &e.Hash) binary.Read(buf, binary.BigEndian, &e.Hash)
} }
if buf.Len() >= 1 {
var pinnedByte byte
binary.Read(buf, binary.BigEndian, &pinnedByte)
e.Pinned = pinnedByte == 1
}
return e, nil return e, nil
} }
@@ -505,10 +495,10 @@ func computeHash(data []byte) uint64 {
} }
func extractHash(data []byte) uint64 { func extractHash(data []byte) uint64 {
if len(data) < 9 { if len(data) < 8 {
return 0 return 0
} }
return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1]) return binary.BigEndian.Uint64(data[len(data)-8:])
} }
func (m *Manager) hasSensitiveMimeType(mimes []string) bool { func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
@@ -519,7 +509,6 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string { func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{ preferredTypes := []string{
"text/uri-list",
"text/plain;charset=utf-8", "text/plain;charset=utf-8",
"text/plain", "text/plain",
"UTF8_STRING", "UTF8_STRING",
@@ -540,14 +529,8 @@ func (m *Manager) selectMimeType(mimes []string) string {
} }
} }
// Skip useless MIME types when falling back if len(mimes) > 0 {
for _, mime := range mimes { return mimes[0]
switch mime {
case "application/vnd.portal.filetransfer":
continue
default:
return mime
}
} }
return "" return ""
@@ -576,62 +559,6 @@ func (m *Manager) imagePreview(data []byte, format string) string {
return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height)
} }
func (m *Manager) uriListPreview(data []byte) (string, bool) {
text := strings.TrimSpace(string(data))
uris := strings.Split(text, "\r\n")
if len(uris) == 0 {
uris = strings.Split(text, "\n")
}
if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") {
filePath := strings.TrimPrefix(uris[0], "file://")
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
if imgData, err := os.ReadFile(filePath); err == nil {
if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); err == nil {
return fmt.Sprintf("[[ file %s %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true
}
}
return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
}
}
if len(uris) > 1 {
return fmt.Sprintf("[[ %d files ]]", len(uris)), false
}
return m.textPreview(data), false
}
func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) {
text := strings.TrimSpace(string(data))
uris := strings.Split(text, "\r\n")
if len(uris) == 0 {
uris = strings.Split(text, "\n")
}
if len(uris) != 1 || !strings.HasPrefix(uris[0], "file://") {
return nil, "", false
}
filePath := strings.TrimPrefix(uris[0], "file://")
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
return nil, "", false
}
imgData, err := os.ReadFile(filePath)
if err != nil {
return nil, "", false
}
_, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData))
if err != nil {
return nil, "", false
}
return imgData, "image/" + imgFmt, true
}
func sizeStr(size int) string { func sizeStr(size int) string {
units := []string{"B", "KiB", "MiB"} units := []string{"B", "KiB", "MiB"}
var i int var i int
@@ -820,82 +747,25 @@ func (m *Manager) DeleteEntry(id uint64) error {
return err return err
} }
func (m *Manager) TouchEntry(id uint64) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
entry, err := m.GetEntry(id)
if err != nil {
return err
}
entry.Timestamp = time.Now()
if err := m.storeEntry(*entry); err != nil {
return err
}
m.updateState()
m.notifySubscribers()
return nil
}
func (m *Manager) ClearHistory() { func (m *Manager) ClearHistory() {
if m.db == nil { if m.db == nil {
return return
} }
// Delete only non-pinned entries
if err := m.db.Update(func(tx *bolt.Tx) error { if err := m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard")) if err := tx.DeleteBucket([]byte("clipboard")); err != nil {
if b == nil {
return nil
}
var toDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
if err != nil || !entry.Pinned {
toDelete = append(toDelete, k)
}
}
for _, k := range toDelete {
if err := b.Delete(k); err != nil {
return err return err
} }
} _, err := tx.CreateBucket([]byte("clipboard"))
return nil return err
}); err != nil { }); err != nil {
log.Errorf("Failed to clear clipboard history: %v", err) log.Errorf("Failed to clear clipboard history: %v", err)
return return
} }
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b != nil {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, _ := decodeEntry(v)
if entry.Pinned {
pinnedCount++
}
}
}
return nil
}); err != nil {
log.Errorf("Failed to count pinned entries: %v", err)
}
if pinnedCount == 0 {
if err := m.compactDB(); err != nil { if err := m.compactDB(); err != nil {
log.Errorf("Failed to compact database: %v", err) log.Errorf("Failed to compact database: %v", err)
} }
}
m.updateState() m.updateState()
m.notifySubscribers() m.notifySubscribers()
@@ -907,23 +777,23 @@ func (m *Manager) compactDB() error {
tmpPath := m.dbPath + ".compact" tmpPath := m.dbPath + ".compact"
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
srcDB, err := bolt.Open(m.dbPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second}) srcDB, err := bolt.Open(m.dbPath, 0644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
if err != nil { if err != nil {
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open source: %w", err) return fmt.Errorf("open source: %w", err)
} }
dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second}) dstDB, err := bolt.Open(tmpPath, 0644, &bolt.Options{Timeout: time.Second})
if err != nil { if err != nil {
srcDB.Close() srcDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open destination: %w", err) return fmt.Errorf("open destination: %w", err)
} }
if err := bolt.Compact(dstDB, srcDB, 0); err != nil { if err := bolt.Compact(dstDB, srcDB, 0); err != nil {
srcDB.Close() srcDB.Close()
dstDB.Close() dstDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("compact: %w", err) return fmt.Errorf("compact: %w", err)
} }
@@ -931,11 +801,11 @@ func (m *Manager) compactDB() error {
dstDB.Close() dstDB.Close()
if err := os.Rename(tmpPath, m.dbPath); err != nil { if err := os.Rename(tmpPath, m.dbPath); err != nil {
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("rename: %w", err) return fmt.Errorf("rename: %w", err)
} }
m.db, err = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) m.db, err = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second})
if err != nil { if err != nil {
return fmt.Errorf("reopen: %w", err) return fmt.Errorf("reopen: %w", err)
} }
@@ -982,21 +852,11 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
} }
}) })
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source m.currentSource = source
m.sourceMutex.Lock() m.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType} m.sourceMimeTypes = []string{mimeType}
m.sourceMutex.Unlock() m.sourceMutex.Unlock()
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil { if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err) log.Errorf("Failed to set selection: %v", err)
@@ -1117,10 +977,6 @@ func (m *Manager) clearOldEntries(days int) error {
if err != nil { if err != nil {
continue continue
} }
// Skip pinned entries
if entry.Pinned {
continue
}
if entry.Timestamp.Before(cutoff) { if entry.Timestamp.Before(cutoff) {
toDelete = append(toDelete, k) toDelete = append(toDelete, k)
} }
@@ -1398,8 +1254,6 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
switch { switch {
case entry.IsImage: case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType) entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default: default:
entry.Preview = m.textPreview(data) entry.Preview = m.textPreview(data)
} }
@@ -1413,390 +1267,3 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
return nil return nil
} }
func (m *Manager) PinEntry(id uint64) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
// Check pinned count
cfg := m.getConfig()
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
pinnedCount++
}
}
return nil
}); err != nil {
log.Errorf("Failed to count pinned entries: %v", err)
}
if pinnedCount >= cfg.MaxPinned {
return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned)
}
err := m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
v := b.Get(itob(id))
if v == nil {
return fmt.Errorf("entry not found")
}
entry, err := decodeEntry(v)
if err != nil {
return err
}
entry.Pinned = true
encoded, err := encodeEntry(entry)
if err != nil {
return err
}
return b.Put(itob(id), encoded)
})
if err == nil {
m.updateState()
m.notifySubscribers()
}
return err
}
func (m *Manager) UnpinEntry(id uint64) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
err := m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
v := b.Get(itob(id))
if v == nil {
return fmt.Errorf("entry not found")
}
entry, err := decodeEntry(v)
if err != nil {
return err
}
entry.Pinned = false
encoded, err := encodeEntry(entry)
if err != nil {
return err
}
return b.Put(itob(id), encoded)
})
if err == nil {
m.updateState()
m.notifySubscribers()
}
return err
}
func (m *Manager) GetPinnedEntries() []Entry {
if m.db == nil {
return nil
}
var pinned []Entry
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
if err != nil {
continue
}
if entry.Pinned {
entry.Data = nil
pinned = append(pinned, entry)
}
}
return nil
}); err != nil {
log.Errorf("Failed to get pinned entries: %v", err)
}
return pinned
}
func (m *Manager) GetPinnedCount() int {
if m.db == nil {
return 0
}
count := 0
if err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
count++
}
}
return nil
}); err != nil {
log.Errorf("Failed to count pinned entries: %v", err)
}
return count
}
func (m *Manager) CopyFile(filePath string) error {
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
cfg := m.getConfig()
if fileInfo.Size() > cfg.MaxEntrySize {
return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize)
}
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
exportedPath, err := m.ExportFileForFlatpak(filePath)
if err != nil {
exportedPath = filePath
}
fileURI := "file://" + exportedPath
if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok {
entry := Entry{
Data: imgData,
MimeType: imgMime,
Size: len(imgData),
Timestamp: time.Now(),
IsImage: true,
Preview: m.imagePreview(imgData, imgMime),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
} else {
entry := Entry{
Data: fileData,
MimeType: "text/uri-list",
Size: len(fileData),
Timestamp: time.Now(),
IsImage: false,
Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)),
}
if err := m.storeEntry(entry); err != nil {
log.Errorf("Failed to store file entry: %v", err)
}
}
m.updateState()
m.notifySubscribers()
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
m.post(func() {
if m.dataControlMgr == nil || m.dataDevice == nil {
log.Error("Data control manager or device not initialized")
return
}
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
source, err := dataMgr.CreateDataSource()
if err != nil {
log.Errorf("Failed to create data source: %v", err)
return
}
type offer struct {
mime string
data []byte
}
offers := []offer{
{"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
{"text/uri-list", []byte(fileURI + "\r\n")},
{"text/plain", []byte(filePath)},
}
if imgErr == nil {
imgMimeType := "image/" + imgMime
offers = append(offers, offer{imgMimeType, fileData})
}
offerData := make(map[string][]byte)
for _, o := range offers {
if err := source.Offer(o.mime); err != nil {
log.Errorf("Failed to offer %s: %v", o.mime, err)
return
}
offerData[o.mime] = o.data
}
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
fd := e.Fd
defer syscall.Close(fd)
file := os.NewFile(uintptr(fd), "clipboard-pipe")
defer file.Close()
if data, ok := offerData[e.MimeType]; ok {
file.Write(data)
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
m.ownerLock.Lock()
m.isOwner = false
m.ownerLock.Unlock()
})
m.currentSource = source
m.ownerLock.Lock()
m.isOwner = true
m.ownerLock.Unlock()
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
if err := device.SetSelection(source); err != nil {
log.Errorf("Failed to set selection: %v", err)
}
})
return nil
}
func (m *Manager) EntryToFile(entry *Entry) string {
switch {
case entry.MimeType == "text/uri-list":
data := strings.TrimSpace(string(entry.Data))
lines := strings.Split(data, "\n")
if len(lines) == 0 {
return ""
}
uri := strings.TrimSuffix(strings.TrimSpace(lines[0]), "\r")
if path, ok := strings.CutPrefix(uri, "file://"); ok {
return path
}
case entry.IsImage:
ext := ".png"
if suffix, ok := strings.CutPrefix(entry.MimeType, "image/"); ok {
ext = "." + suffix
}
cacheDir, err := os.UserCacheDir()
if err != nil {
return ""
}
clipDir := filepath.Join(cacheDir, "dms", "clipboard")
if err := os.MkdirAll(clipDir, 0o755); err != nil {
return ""
}
filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
if os.WriteFile(filePath, entry.Data, 0o644) != nil {
return ""
}
return filePath
}
return ""
}
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
if _, err := os.Stat(filePath); err != nil {
return "", fmt.Errorf("file not found: %w", err)
}
if m.dbusConn == nil {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return "", fmt.Errorf("connect session bus: %w", err)
}
if !conn.SupportsUnixFDs() {
conn.Close()
return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
}
m.dbusConn = conn
}
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("open file: %w", err)
}
fd := int(file.Fd())
portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
var docIds []string
var extra map[string]dbus.Variant
flags := uint32(0)
err = portal.Call(
"org.freedesktop.portal.Documents.AddFull",
0,
[]dbus.UnixFD{dbus.UnixFD(fd)},
flags,
"",
[]string{},
).Store(&docIds, &extra)
file.Close()
if err != nil {
return "", fmt.Errorf("AddFull: %w", err)
}
if len(docIds) == 0 {
return "", fmt.Errorf("no doc IDs returned")
}
docId := docIds[0]
for _, app := range getInstalledFlatpaks() {
_ = portal.Call(
"org.freedesktop.portal.Documents.GrantPermissions",
0,
docId,
app,
[]string{"read"},
).Err
}
uid := os.Getuid()
basename := filepath.Base(filePath)
exportedPath := fmt.Sprintf("/run/user/%d/doc/%s/%s", uid, docId, basename)
return exportedPath, nil
}
func getInstalledFlatpaks() []string {
out, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
if err != nil {
return nil
}
var apps []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if app := strings.TrimSpace(line); app != "" {
apps = append(apps, app)
}
}
return apps
}

View File

@@ -7,7 +7,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/godbus/dbus/v5"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
@@ -20,7 +19,6 @@ type Config struct {
AutoClearDays int `json:"autoClearDays"` AutoClearDays int `json:"autoClearDays"`
ClearAtStartup bool `json:"clearAtStartup"` ClearAtStartup bool `json:"clearAtStartup"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
MaxPinned int `json:"maxPinned"`
} }
func DefaultConfig() Config { func DefaultConfig() Config {
@@ -29,7 +27,6 @@ func DefaultConfig() Config {
MaxEntrySize: 5 * 1024 * 1024, MaxEntrySize: 5 * 1024 * 1024,
AutoClearDays: 0, AutoClearDays: 0,
ClearAtStartup: false, ClearAtStartup: false,
MaxPinned: 25,
} }
} }
@@ -66,7 +63,7 @@ func SaveConfig(cfg Config) error {
return err return err
} }
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err return err
} }
@@ -75,7 +72,7 @@ func SaveConfig(cfg Config) error {
return err return err
} }
return os.WriteFile(path, data, 0o644) return os.WriteFile(path, data, 0644)
} }
type SearchParams struct { type SearchParams struct {
@@ -103,7 +100,6 @@ type Entry struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
IsImage bool `json:"isImage"` IsImage bool `json:"isImage"`
Hash uint64 `json:"hash,omitempty"` Hash uint64 `json:"hash,omitempty"`
Pinned bool `json:"pinned"`
} }
type State struct { type State struct {
@@ -158,8 +154,6 @@ type Manager struct {
dirty chan struct{} dirty chan struct{}
notifierWg sync.WaitGroup notifierWg sync.WaitGroup
lastState *State lastState *State
dbusConn *dbus.Conn
} }
func (m *Manager) GetState() State { func (m *Manager) GetState() State {

View File

@@ -1,237 +0,0 @@
package dbus
import (
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
type objectParams struct {
bus string
dest string
path string
iface string
}
func extractObjectParams(p map[string]any, requirePath bool) (objectParams, error) {
bus, err := params.String(p, "bus")
if err != nil {
return objectParams{}, err
}
dest, err := params.String(p, "dest")
if err != nil {
return objectParams{}, err
}
var path string
if requirePath {
path, err = params.String(p, "path")
if err != nil {
return objectParams{}, err
}
} else {
path = params.StringOpt(p, "path", "/")
}
iface, err := params.String(p, "interface")
if err != nil {
return objectParams{}, err
}
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
}
func HandleRequest(conn net.Conn, req models.Request, m *Manager, clientID string) {
switch req.Method {
case "dbus.call":
handleCall(conn, req, m)
case "dbus.getProperty":
handleGetProperty(conn, req, m)
case "dbus.setProperty":
handleSetProperty(conn, req, m)
case "dbus.getAllProperties":
handleGetAllProperties(conn, req, m)
case "dbus.introspect":
handleIntrospect(conn, req, m)
case "dbus.listNames":
handleListNames(conn, req, m)
case "dbus.subscribe":
handleSubscribe(conn, req, m, clientID)
case "dbus.unsubscribe":
handleUnsubscribe(conn, req, m)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleCall(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
method, err := params.String(req.Params, "method")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
var args []any
if argsRaw, ok := params.Any(req.Params, "args"); ok {
if argsSlice, ok := argsRaw.([]any); ok {
args = argsSlice
}
}
result, err := m.Call(op.bus, op.dest, op.path, op.iface, method, args)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleGetProperty(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
property, err := params.String(req.Params, "property")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
result, err := m.GetProperty(op.bus, op.dest, op.path, op.iface, property)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleSetProperty(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
property, err := params.String(req.Params, "property")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
value, ok := params.Any(req.Params, "value")
if !ok {
models.RespondError(conn, req.ID, "missing 'value' parameter")
return
}
if err := m.SetProperty(op.bus, op.dest, op.path, op.iface, property, value); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
}
func handleGetAllProperties(conn net.Conn, req models.Request, m *Manager) {
op, err := extractObjectParams(req.Params, true)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
result, err := m.GetAllProperties(op.bus, op.dest, op.path, op.iface)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleIntrospect(conn net.Conn, req models.Request, m *Manager) {
bus, err := params.String(req.Params, "bus")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
dest, err := params.String(req.Params, "dest")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
path := params.StringOpt(req.Params, "path", "/")
result, err := m.Introspect(bus, dest, path)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleListNames(conn net.Conn, req models.Request, m *Manager) {
bus, err := params.String(req.Params, "bus")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
result, err := m.ListNames(bus)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
bus, err := params.String(req.Params, "bus")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
sender := params.StringOpt(req.Params, "sender", "")
path := params.StringOpt(req.Params, "path", "")
iface := params.StringOpt(req.Params, "interface", "")
member := params.StringOpt(req.Params, "member", "")
result, err := m.Subscribe(clientID, bus, sender, path, iface, member)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}
func handleUnsubscribe(conn net.Conn, req models.Request, m *Manager) {
subID, err := params.String(req.Params, "subscriptionId")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.Unsubscribe(subID); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
}

View File

@@ -1,394 +0,0 @@
package dbus
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5"
)
func NewManager() (*Manager, error) {
systemConn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
}
sessionConn, err := dbus.ConnectSessionBus()
if err != nil {
systemConn.Close()
return nil, fmt.Errorf("failed to connect to session bus: %w", err)
}
m := &Manager{
systemConn: systemConn,
sessionConn: sessionConn,
}
go m.processSystemSignals()
go m.processSessionSignals()
return m, nil
}
func (m *Manager) getConn(bus string) (*dbus.Conn, error) {
switch bus {
case "system":
if m.systemConn == nil {
return nil, fmt.Errorf("system bus not connected")
}
return m.systemConn, nil
case "session":
if m.sessionConn == nil {
return nil, fmt.Errorf("session bus not connected")
}
return m.sessionConn, nil
default:
return nil, fmt.Errorf("invalid bus: %s (must be 'system' or 'session')", bus)
}
}
func (m *Manager) Call(bus, dest, path, iface, method string, args []any) (*CallResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
fullMethod := iface + "." + method
convertedArgs := convertArgs(args)
call := obj.Call(fullMethod, 0, convertedArgs...)
if call.Err != nil {
return nil, fmt.Errorf("dbus call failed: %w", call.Err)
}
return &CallResult{Values: dbusutil.NormalizeSlice(call.Body)}, nil
}
func convertArgs(args []any) []any {
result := make([]any, len(args))
for i, arg := range args {
result[i] = convertArg(arg)
}
return result
}
func convertArg(arg any) any {
switch v := arg.(type) {
case float64:
if v == float64(uint32(v)) && v >= 0 && v <= float64(^uint32(0)) {
return uint32(v)
}
if v == float64(int32(v)) {
return int32(v)
}
return v
case []any:
return convertArgs(v)
case map[string]any:
result := make(map[string]any)
for k, val := range v {
result[k] = convertArg(val)
}
return result
default:
return arg
}
}
func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
var variant dbus.Variant
err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, iface, property).Store(&variant)
if err != nil {
return nil, fmt.Errorf("failed to get property: %w", err)
}
return &PropertyResult{Value: dbusutil.Normalize(variant.Value())}, nil
}
func (m *Manager) SetProperty(bus, dest, path, iface, property string, value any) error {
conn, err := m.getConn(bus)
if err != nil {
return err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
call := obj.Call("org.freedesktop.DBus.Properties.Set", 0, iface, property, dbus.MakeVariant(value))
if call.Err != nil {
return fmt.Errorf("failed to set property: %w", call.Err)
}
return nil
}
func (m *Manager) GetAllProperties(bus, dest, path, iface string) (map[string]any, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
var props map[string]dbus.Variant
err = obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface).Store(&props)
if err != nil {
return nil, fmt.Errorf("failed to get properties: %w", err)
}
result := make(map[string]any)
for k, v := range props {
result[k] = dbusutil.Normalize(v.Value())
}
return result, nil
}
func (m *Manager) Introspect(bus, dest, path string) (*IntrospectResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
obj := conn.Object(dest, dbus.ObjectPath(path))
var xml string
err = obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml)
if err != nil {
return nil, fmt.Errorf("failed to introspect: %w", err)
}
return &IntrospectResult{XML: xml}, nil
}
func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
var names []string
err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
if err != nil {
return nil, fmt.Errorf("failed to list names: %w", err)
}
return &ListNamesResult{Names: names}, nil
}
func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
conn, err := m.getConn(bus)
if err != nil {
return nil, err
}
subID := generateSubscriptionID()
parts := []string{"type='signal'"}
if sender != "" {
parts = append(parts, fmt.Sprintf("sender='%s'", sender))
}
if path != "" {
parts = append(parts, fmt.Sprintf("path='%s'", path))
}
if iface != "" {
parts = append(parts, fmt.Sprintf("interface='%s'", iface))
}
if member != "" {
parts = append(parts, fmt.Sprintf("member='%s'", member))
}
matchRule := strings.Join(parts, ",")
call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule)
if call.Err != nil {
return nil, fmt.Errorf("failed to add match rule: %w", call.Err)
}
sub := &signalSubscription{
Bus: bus,
Sender: sender,
Path: path,
Interface: iface,
Member: member,
ClientID: clientID,
}
m.subscriptions.Store(subID, sub)
log.Debugf("dbus: subscribed %s to %s", subID, matchRule)
return &SubscribeResult{SubscriptionID: subID}, nil
}
func (m *Manager) Unsubscribe(subID string) error {
sub, ok := m.subscriptions.LoadAndDelete(subID)
if !ok {
return fmt.Errorf("subscription not found: %s", subID)
}
conn, err := m.getConn(sub.Bus)
if err != nil {
return err
}
parts := []string{"type='signal'"}
if sub.Sender != "" {
parts = append(parts, fmt.Sprintf("sender='%s'", sub.Sender))
}
if sub.Path != "" {
parts = append(parts, fmt.Sprintf("path='%s'", sub.Path))
}
if sub.Interface != "" {
parts = append(parts, fmt.Sprintf("interface='%s'", sub.Interface))
}
if sub.Member != "" {
parts = append(parts, fmt.Sprintf("member='%s'", sub.Member))
}
matchRule := strings.Join(parts, ",")
call := conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule)
if call.Err != nil {
log.Warnf("dbus: failed to remove match rule: %v", call.Err)
}
log.Debugf("dbus: unsubscribed %s", subID)
return nil
}
func (m *Manager) UnsubscribeClient(clientID string) {
var toDelete []string
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
if sub.ClientID == clientID {
toDelete = append(toDelete, subID)
}
return true
})
for _, subID := range toDelete {
if err := m.Unsubscribe(subID); err != nil {
log.Warnf("dbus: failed to unsubscribe %s: %v", subID, err)
}
}
}
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
ch := make(chan SignalEvent, 64)
existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch)
if loaded {
return existing
}
return ch
}
func (m *Manager) UnsubscribeSignals(clientID string) {
if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok {
close(ch)
}
m.UnsubscribeClient(clientID)
}
func (m *Manager) processSystemSignals() {
if m.systemConn == nil {
return
}
ch := make(chan *dbus.Signal, 256)
m.systemConn.Signal(ch)
for sig := range ch {
m.dispatchSignal("system", sig)
}
}
func (m *Manager) processSessionSignals() {
if m.sessionConn == nil {
return
}
ch := make(chan *dbus.Signal, 256)
m.sessionConn.Signal(ch)
for sig := range ch {
m.dispatchSignal("session", sig)
}
}
func (m *Manager) dispatchSignal(bus string, sig *dbus.Signal) {
path := string(sig.Path)
iface := ""
member := sig.Name
if idx := strings.LastIndex(sig.Name, "."); idx != -1 {
iface = sig.Name[:idx]
member = sig.Name[idx+1:]
}
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
if sub.Bus != bus {
return true
}
if sub.Path != "" && sub.Path != path && !strings.HasPrefix(path, sub.Path) {
return true
}
if sub.Interface != "" && sub.Interface != iface {
return true
}
if sub.Member != "" && sub.Member != member {
return true
}
event := SignalEvent{
SubscriptionID: subID,
Sender: sig.Sender,
Path: path,
Interface: iface,
Member: member,
Body: dbusutil.NormalizeSlice(sig.Body),
}
ch, ok := m.signalSubscribers.Load(sub.ClientID)
if !ok {
return true
}
select {
case ch <- event:
default:
log.Warnf("dbus: channel full for %s, dropping signal", subID)
}
return true
})
}
func (m *Manager) Close() {
m.signalSubscribers.Range(func(clientID string, ch chan SignalEvent) bool {
close(ch)
m.signalSubscribers.Delete(clientID)
return true
})
if m.systemConn != nil {
m.systemConn.Close()
}
if m.sessionConn != nil {
m.sessionConn.Close()
}
}
func generateSubscriptionID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
log.Warnf("dbus: failed to generate random subscription ID: %v", err)
}
return hex.EncodeToString(b)
}

View File

@@ -1,52 +0,0 @@
package dbus
import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5"
)
type Manager struct {
systemConn *dbus.Conn
sessionConn *dbus.Conn
subscriptions syncmap.Map[string, *signalSubscription]
signalSubscribers syncmap.Map[string, chan SignalEvent]
}
type signalSubscription struct {
Bus string
Sender string
Path string
Interface string
Member string
ClientID string
}
type SignalEvent struct {
SubscriptionID string `json:"subscriptionId"`
Sender string `json:"sender"`
Path string `json:"path"`
Interface string `json:"interface"`
Member string `json:"member"`
Body []any `json:"body"`
}
type CallResult struct {
Values []any `json:"values"`
}
type PropertyResult struct {
Value any `json:"value"`
}
type IntrospectResult struct {
XML string `json:"xml"`
}
type ListNamesResult struct {
Names []string `json:"names"`
}
type SubscribeResult struct {
SubscriptionID string `json:"subscriptionId"`
}

View File

@@ -6,7 +6,6 @@ import (
"os" "os"
"sync" "sync"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -111,17 +110,61 @@ func (m *Manager) updateAccountsState() error {
m.stateMutex.Lock() m.stateMutex.Lock()
defer m.stateMutex.Unlock() defer m.stateMutex.Unlock()
m.state.Accounts.IconFile = dbusutil.GetOr(props, "IconFile", "") if v, ok := props["IconFile"]; ok {
m.state.Accounts.RealName = dbusutil.GetOr(props, "RealName", "") if val, ok := v.Value().(string); ok {
m.state.Accounts.UserName = dbusutil.GetOr(props, "UserName", "") m.state.Accounts.IconFile = val
m.state.Accounts.AccountType = dbusutil.GetOr(props, "AccountType", int32(0)) }
m.state.Accounts.HomeDirectory = dbusutil.GetOr(props, "HomeDirectory", "") }
m.state.Accounts.Shell = dbusutil.GetOr(props, "Shell", "") if v, ok := props["RealName"]; ok {
m.state.Accounts.Email = dbusutil.GetOr(props, "Email", "") if val, ok := v.Value().(string); ok {
m.state.Accounts.Language = dbusutil.GetOr(props, "Language", "") m.state.Accounts.RealName = val
m.state.Accounts.Location = dbusutil.GetOr(props, "Location", "") }
m.state.Accounts.Locked = dbusutil.GetOr(props, "Locked", false) }
m.state.Accounts.PasswordMode = dbusutil.GetOr(props, "PasswordMode", int32(0)) if v, ok := props["UserName"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.UserName = val
}
}
if v, ok := props["AccountType"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.AccountType = val
}
}
if v, ok := props["HomeDirectory"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.HomeDirectory = val
}
}
if v, ok := props["Shell"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Shell = val
}
}
if v, ok := props["Email"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Email = val
}
}
if v, ok := props["Language"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Language = val
}
}
if v, ok := props["Location"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Location = val
}
}
if v, ok := props["Locked"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.Accounts.Locked = val
}
}
if v, ok := props["PasswordMode"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.PasswordMode = val
}
}
return nil return nil
} }
@@ -137,7 +180,7 @@ func (m *Manager) updateSettingsState() error {
return err return err
} }
if colorScheme, ok := dbusutil.As[uint32](variant); ok { if colorScheme, ok := variant.Value().(uint32); ok {
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.Settings.ColorScheme = colorScheme m.state.Settings.ColorScheme = colorScheme
m.stateMutex.Unlock() m.stateMutex.Unlock()

View File

@@ -7,7 +7,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -133,15 +132,37 @@ func (m *Manager) updateSessionState() error {
m.stateMutex.Lock() m.stateMutex.Lock()
defer m.stateMutex.Unlock() defer m.stateMutex.Unlock()
m.state.Active = dbusutil.GetOr(props, "Active", m.state.Active) if v, ok := props["Active"]; ok {
m.state.IdleHint = dbusutil.GetOr(props, "IdleHint", m.state.IdleHint) if val, ok := v.Value().(bool); ok {
m.state.IdleSinceHint = dbusutil.GetOr(props, "IdleSinceHint", m.state.IdleSinceHint) m.state.Active = val
if lockedHint, ok := dbusutil.Get[bool](props, "LockedHint"); ok { }
m.state.LockedHint = lockedHint }
m.state.Locked = lockedHint if v, ok := props["IdleHint"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.IdleHint = val
}
}
if v, ok := props["IdleSinceHint"]; ok {
if val, ok := v.Value().(uint64); ok {
m.state.IdleSinceHint = val
}
}
if v, ok := props["LockedHint"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.LockedHint = val
m.state.Locked = val
}
}
if v, ok := props["Type"]; ok {
if val, ok := v.Value().(string); ok {
m.state.SessionType = val
}
}
if v, ok := props["Class"]; ok {
if val, ok := v.Value().(string); ok {
m.state.SessionClass = val
}
} }
m.state.SessionType = dbusutil.GetOr(props, "Type", m.state.SessionType)
m.state.SessionClass = dbusutil.GetOr(props, "Class", m.state.SessionClass)
if v, ok := props["User"]; ok { if v, ok := props["User"]; ok {
if userArr, ok := v.Value().([]any); ok && len(userArr) >= 1 { if userArr, ok := v.Value().([]any); ok && len(userArr) >= 1 {
if uid, ok := userArr[0].(uint32); ok { if uid, ok := userArr[0].(uint32); ok {
@@ -149,12 +170,36 @@ func (m *Manager) updateSessionState() error {
} }
} }
} }
m.state.UserName = dbusutil.GetOr(props, "Name", m.state.UserName) if v, ok := props["Name"]; ok {
m.state.RemoteHost = dbusutil.GetOr(props, "RemoteHost", m.state.RemoteHost) if val, ok := v.Value().(string); ok {
m.state.Service = dbusutil.GetOr(props, "Service", m.state.Service) m.state.UserName = val
m.state.TTY = dbusutil.GetOr(props, "TTY", m.state.TTY) }
m.state.Display = dbusutil.GetOr(props, "Display", m.state.Display) }
m.state.Remote = dbusutil.GetOr(props, "Remote", m.state.Remote) if v, ok := props["RemoteHost"]; ok {
if val, ok := v.Value().(string); ok {
m.state.RemoteHost = val
}
}
if v, ok := props["Service"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Service = val
}
}
if v, ok := props["TTY"]; ok {
if val, ok := v.Value().(string); ok {
m.state.TTY = val
}
}
if v, ok := props["Display"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Display = val
}
}
if v, ok := props["Remote"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.Remote = val
}
}
if v, ok := props["Seat"]; ok { if v, ok := props["Seat"]; ok {
if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 { if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
if seatID, ok := seatArr[0].(string); ok { if seatID, ok := seatArr[0].(string); ok {
@@ -162,7 +207,11 @@ func (m *Manager) updateSessionState() error {
} }
} }
} }
m.state.VTNr = dbusutil.GetOr(props, "VTNr", m.state.VTNr) if v, ok := props["VTNr"]; ok {
if val, ok := v.Value().(uint32); ok {
m.state.VTNr = val
}
}
return nil return nil
} }

View File

@@ -3,7 +3,6 @@ package loginctl
import ( import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -118,28 +117,31 @@ func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) {
for key, variant := range changes { for key, variant := range changes {
switch key { switch key {
case "Active": case "Active":
if val, ok := dbusutil.As[bool](variant); ok { if val, ok := variant.Value().(bool); ok {
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.Active = val m.state.Active = val
m.stateMutex.Unlock() m.stateMutex.Unlock()
needsUpdate = true needsUpdate = true
} }
case "IdleHint": case "IdleHint":
if val, ok := dbusutil.As[bool](variant); ok { if val, ok := variant.Value().(bool); ok {
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.IdleHint = val m.state.IdleHint = val
m.stateMutex.Unlock() m.stateMutex.Unlock()
needsUpdate = true needsUpdate = true
} }
case "IdleSinceHint": case "IdleSinceHint":
if val, ok := dbusutil.As[uint64](variant); ok { if val, ok := variant.Value().(uint64); ok {
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.IdleSinceHint = val m.state.IdleSinceHint = val
m.stateMutex.Unlock() m.stateMutex.Unlock()
needsUpdate = true needsUpdate = true
} }
case "LockedHint": case "LockedHint":
if val, ok := dbusutil.As[bool](variant); ok { if val, ok := variant.Value().(bool); ok {
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.LockedHint = val m.state.LockedHint = val
m.state.Locked = val m.state.Locked = val

View File

@@ -13,7 +13,6 @@ const (
dbusNMPath = "/org/freedesktop/NetworkManager" dbusNMPath = "/org/freedesktop/NetworkManager"
dbusNMInterface = "org.freedesktop.NetworkManager" dbusNMInterface = "org.freedesktop.NetworkManager"
dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device" dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device"
dbusNMWiredInterface = "org.freedesktop.NetworkManager.Device.Wired"
dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless" dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless"
dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint" dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint"
dbusPropsInterface = "org.freedesktop.DBus.Properties" dbusPropsInterface = "org.freedesktop.DBus.Properties"

View File

@@ -81,24 +81,44 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err return err
} }
for _, info := range b.wifiDevices { if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
conn.RemoveSignal(signals) conn.RemoveSignal(signals)
conn.Close() conn.Close()
return err return err
} }
} }
for _, info := range b.ethernetDevices { if b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
conn.RemoveSignal(signals) conn.RemoveSignal(signals)
conn.Close() conn.Close()
return err return err
@@ -137,17 +157,19 @@ func (b *NetworkManagerBackend) stopSignalPump() {
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
for _, info := range b.wifiDevices { if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
} }
for _, info := range b.ethernetDevices { if b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
@@ -210,10 +232,7 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
b.handleNetworkManagerChange(changes) b.handleNetworkManagerChange(changes)
case dbusNMDeviceInterface: case dbusNMDeviceInterface:
b.handleDeviceChange(sig.Path, changes) b.handleDeviceChange(changes)
case dbusNMWiredInterface:
b.handleWiredChange(changes)
case dbusNMWirelessInterface: case dbusNMWirelessInterface:
b.handleWiFiChange(changes) b.handleWiFiChange(changes)
@@ -259,10 +278,9 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db
} }
} }
func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, changes map[string]dbus.Variant) { func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Variant) {
var needsUpdate bool var needsUpdate bool
var stateChanged bool var stateChanged bool
var managedChanged bool
for key := range changes { for key := range changes {
switch key { switch key {
@@ -271,61 +289,21 @@ func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, c
needsUpdate = true needsUpdate = true
case "Ip4Config": case "Ip4Config":
needsUpdate = true needsUpdate = true
case "Managed":
managedChanged = true
default: default:
continue continue
} }
} }
if managedChanged { if needsUpdate {
if managedVariant, ok := changes["Managed"]; ok {
if managed, ok := managedVariant.Value().(bool); ok && managed {
b.handleDeviceAdded(devicePath)
return
}
}
}
if !needsUpdate {
return
}
b.updateAllEthernetDevices()
b.updateEthernetState() b.updateEthernetState()
b.updateAllWiFiDevices()
b.updateWiFiState() b.updateWiFiState()
if stateChanged { if stateChanged {
b.listEthernetConnections()
b.updatePrimaryConnection() b.updatePrimaryConnection()
} }
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
} }
func (b *NetworkManagerBackend) handleWiredChange(changes map[string]dbus.Variant) {
var needsUpdate bool
for key := range changes {
switch key {
case "Carrier", "Speed", "HwAddress":
needsUpdate = true
default:
continue
}
}
if !needsUpdate {
return
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.updatePrimaryConnection()
if b.onStateChange != nil {
b.onStateChange()
}
} }
func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) { func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) {
@@ -391,18 +369,6 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
return return
} }
if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi {
return
}
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
managed, _ := dev.GetPropertyManaged() managed, _ := dev.GetPropertyManaged()
if !managed { if !managed {
return return
@@ -432,6 +398,14 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
b.ethernetDevice = dev b.ethernetDevice = dev
} }
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllEthernetDevices() b.updateAllEthernetDevices()
b.updateEthernetState() b.updateEthernetState()
b.listEthernetConnections() b.listEthernetConnections()
@@ -456,6 +430,14 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
b.wifiDev = w b.wifiDev = w
} }
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllWiFiDevices() b.updateAllWiFiDevices()
b.updateWiFiState() b.updateWiFiState()
} }

View File

@@ -159,7 +159,7 @@ func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) {
} }
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes) backend.handleDeviceChange(changes)
}) })
} }
@@ -174,7 +174,7 @@ func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) {
} }
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes) backend.handleDeviceChange(changes)
}) })
} }

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