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

Compare commits

..

21 Commits

Author SHA1 Message Date
bbedward
e022c04519 bump version 2026-01-15 23:45:21 -05:00
bbedward
f534384e5e cc: wrap icons in fixed size containers
maybe #1376
2026-01-15 23:45:09 -05:00
bbedward
a25cdb43d5 controlcenter: fix visibility condition of no icons fixes #1377 2026-01-15 23:45:09 -05:00
purian23
4e9b4ca400 Fix fedora version format 2026-01-15 23:44:19 -05:00
bbedward
5bab1c98b1 plugins: fix plugin confirm third part repo window 2026-01-15 23:44:19 -05:00
purian23
2284bb002f distro: Update Fedora dynamic versioning 2026-01-15 23:44:19 -05:00
purian23
b0611d6104 feat: Allow more pinned services in Control Center/Settings 2026-01-15 23:44:19 -05:00
purian23
27965862d6 core: Update ghostty on dankinstall 2026-01-15 23:44:19 -05:00
Abhinav Chalise
e74a901e05 fix volume osd sliding ui update for vertical layout (#1382) 2026-01-15 23:44:19 -05:00
bbedward
77794deb2c widgets: add fallback for steam apps 2026-01-15 23:44:19 -05:00
Lucas
1c10746e50 doctor: use dbus for checking on services (#1384)
* doctor: use dbus for checking on services

* doctor: show docs URL for failed checks

* core: remove unused function
2026-01-15 23:44:19 -05:00
bbedward
8ecb7282b9 dankdash: fix weather open IPC fixes #1367 2026-01-15 23:44:19 -05:00
bbedward
9b3fa804ab matugen: fix nvim ID in skipTemplates 2026-01-15 23:44:19 -05:00
purian23
b2ad31a27e dankdash: Center Media Art & Controls 2026-01-15 23:44:19 -05:00
bbedward
db17e4cb14 i18n: update terms 2026-01-15 23:44:02 -05:00
bbedward
1b7dcf56a8 bump VERSION 2026-01-14 08:00:15 -05:00
bbedward
502bb88e92 modals: fix wifi passowrd, polkit, and VPN import 2026-01-14 08:00:05 -05:00
bbedward
b76d0ce97d settings: fix child windows on newer quickshell-git 2026-01-13 16:58:08 -05:00
bbedward
fa66d330cf bump VERSION 2026-01-13 16:42:49 -05:00
Lucas
157eab2d07 settings: fix modal not opening on latest quickshell (#1357) 2026-01-13 16:42:38 -05:00
Lucas
f50ad2dc22 nix: escape version string (#1353) 2026-01-13 16:42:38 -05:00
338 changed files with 13623 additions and 60031 deletions

View File

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

View File

@@ -1,5 +1,5 @@
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:
- enhancement
body:

View File

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

View File

@@ -20,10 +20,5 @@ jobs:
- name: Add a flatpak that mutagen could support
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: core/go.mod
- name: run pre-commit hooks
uses: j178/prek-action@v1

View File

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

View File

@@ -12,7 +12,7 @@ on:
required: false
default: ""
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:
check-updates:

View File

@@ -10,11 +10,3 @@ repos:
hooks:
- id: shellcheck
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.
# 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
- Added clipboard and clipboard history integration

View File

@@ -43,6 +43,7 @@ install-shell:
@mkdir -p $(SHELL_INSTALL_DIR)
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@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"
install-completions:
@@ -58,10 +59,10 @@ install-completions:
install-systemd:
@echo "Installing systemd user service..."
@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
@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"
install-icon:

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

@@ -12,21 +12,17 @@ import (
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"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/log"
@@ -52,7 +48,6 @@ var (
clipCopyForeground bool
clipCopyPasteOnce bool
clipCopyType string
clipCopyDownload bool
clipJSONOutput bool
)
@@ -189,12 +184,11 @@ func init() {
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().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")
clipHistoryCmd.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(&clipSearchOffset, "offset", "o", 0, "Result offset")
@@ -221,10 +215,9 @@ func init() {
func runClipCopy(cmd *cobra.Command, args []string) {
var data []byte
switch {
case len(args) > 0:
if len(args) > 0 {
data = []byte(args[0])
default:
} else {
var err error
data, err = io.ReadAll(os.Stdin)
if err != nil {
@@ -232,68 +225,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 {
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) {
data, _, err := clipboard.Paste()
if err != nil {
@@ -450,13 +386,16 @@ func runClipGet(cmd *cobra.Command, args []string) {
req := models.Request{
ID: 1,
Method: "clipboard.copyEntry",
Params: map[string]any{"id": id},
Params: map[string]any{
"id": id,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to copy clipboard entry: %v", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
@@ -733,7 +672,7 @@ func runClipExport(cmd *cobra.Command, args []string) {
return
}
if err := os.WriteFile(args[0], out, 0o644); err != nil {
if err := os.WriteFile(args[0], out, 0644); err != nil {
log.Fatalf("Failed to write file: %v", err)
}
fmt.Printf("Exported to %s\n", args[0])
@@ -788,7 +727,7 @@ func runClipMigrate(cmd *cobra.Command, args []string) {
log.Fatalf("Cliphist db not found: %s", dbPath)
}
db, err := bolt.Open(dbPath, 0o644, &bolt.Options{
db, err := bolt.Open(dbPath, 0644, &bolt.Options{
ReadOnly: true,
Timeout: 1 * time.Second,
})
@@ -856,113 +795,3 @@ func detectMimeType(data []byte) string {
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{
Use: "ipc [target] [function] [args...]",
Use: "ipc",
Short: "Send IPC commands to running DMS shell",
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
PreRunE: findConfig,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = 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{
Use: "debug-srv",
Short: "Start the debug server",
@@ -517,11 +511,8 @@ func getCommonCommands() []*cobra.Command {
colorCmd,
screenshotCmd,
notifyActionCmd,
notifyCmd,
genericNotifyActionCmd,
matugenCmd,
clipboardCmd,
chromaCmd,
doctorCmd,
configCmd,
}

View File

@@ -87,8 +87,6 @@ var (
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
)
var doctorCmd = &cobra.Command{
@@ -450,13 +448,11 @@ func checkWindowManagers() []checkResult {
versionRegex *regexp.Regexp
commands []string
}{
{"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
{"Hyprland", "hyprctl", "version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}},
{"niri", "niri", "--version", niriVersionRegex, []string{"niri"}},
{"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}},
{"River", "river", "-version", riverVersionRegex, []string{"river"}},
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
}
var results []checkResult
@@ -502,8 +498,8 @@ func checkWindowManagers() []checkResult {
}
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
output, err := exec.Command(cmd, arg).CombinedOutput()
if err != nil && len(output) == 0 {
output, err := exec.Command(cmd, arg).Output()
if err != nil {
return "installed"
}
@@ -591,7 +587,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
}
@@ -752,7 +748,7 @@ func checkConfigurationFiles() []checkResult {
status := statusOK
message := "Present"
if info.Mode().Perm()&0o200 == 0 {
if info.Mode().Perm()&0200 == 0 {
status = statusWarn
message += " (read-only)"
}

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"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dms"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
@@ -18,9 +20,11 @@ var rootCmd = &cobra.Command{
Use: "dms",
Short: "dms CLI",
Long: "dms is the DankMaterialShell management CLI and backend server.",
Run: runInteractiveMode,
}
func init() {
// Add the -c flag
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
}
@@ -34,7 +38,7 @@ func findConfig(cmd *cobra.Command, args []string) error {
if statErr == nil && !info.IsDir() {
configPath = customConfigPath
log.Debug("Using config from: %s", configPath)
return nil
return nil // <-- Guard statement
}
if statErr != nil {
@@ -72,3 +76,18 @@ func findConfig(cmd *cobra.Command, args []string) error {
log.Debug("Using config from: %s", configPath)
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) {
if len(args) == 0 {
printIPCHelp()
return
log.Error("IPC command requires arguments")
log.Info("Usage: dms ipc <command> [args...]")
os.Exit(1)
}
if args[0] != "call" {
@@ -641,45 +642,3 @@ func runShellIPCCommand(args []string) {
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,7 +4,6 @@ go 1.24.6
require (
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/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
@@ -13,28 +12,25 @@ require (
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
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-20251203232544-981d4ecc17c3
github.com/spf13/cobra v1.10.2
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
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/image v0.35.0
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
golang.org/x/image v0.34.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.8.0 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.2 // 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/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-20251217170237-e9738f50a3cd // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -42,8 +38,8 @@ require (
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
)
require (
@@ -51,12 +47,12 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // 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.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20260123133532-f99a98e81ce9
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -70,7 +66,7 @@ require (
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.40.0
golang.org/x/text v0.33.0
golang.org/x/sys v0.39.0
golang.org/x/text v0.32.0
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/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
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/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@@ -24,6 +16,8 @@ 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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
@@ -32,30 +26,34 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
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.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
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/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -66,15 +64,22 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY=
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc=
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.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
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-20260123133532-f99a98e81ce9/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s=
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-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k=
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0=
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs=
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-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs=
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 h1:0lz2eJScP8v5YZQsrEw+ggWC5jNySjg4bIZo5BIh6iI=
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19/go.mod h1:L+Evfcs7EdTqxwv854354cb6+++7TFL3hJn3Wy4g+3w=
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/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
@@ -82,8 +87,6 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
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/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/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -124,12 +127,16 @@ 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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E=
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI=
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3 h1:msKaIZrrNpvofLPDzNBW3152PJBsnPZsoNNosOCS+C0=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3/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/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -139,43 +146,45 @@ 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/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/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/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
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/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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
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 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -330,163 +330,3 @@ func selectPreferredMimeType(mimes []string) string {
func IsImageMimeType(mime string) bool {
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

@@ -60,7 +60,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
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 {
return fmt.Errorf("open db: %w", err)
}
@@ -132,7 +132,7 @@ func GetDBPath() (string, error) {
oldPath := filepath.Join(oldDir, "db")
if _, err := os.Stat(oldPath); err == nil {
if err := os.MkdirAll(newDir, 0o700); err != nil {
if err := os.MkdirAll(newDir, 0700); err != nil {
return "", err
}
if err := os.Rename(oldPath, newPath); err != nil {
@@ -142,7 +142,7 @@ func GetDBPath() (string, error) {
return newPath, nil
}
if err := os.MkdirAll(newDir, 0o700); err != nil {
if err := os.MkdirAll(newDir, 0700); err != nil {
return "", err
}
return newPath, nil

View File

@@ -2,7 +2,6 @@ package clipboard
import (
"context"
"errors"
"fmt"
"io"
"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 {
return fmt.Errorf("set read deadline: %w", err)
}
if err := wlCtx.Dispatch(); err != nil {
if isTimeoutError(err) {
continue
}
if err := wlCtx.Dispatch(); err != nil && err != os.ErrDeadlineExceeded {
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) {
ch := make(chan ClipboardChange, 16)
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)
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)
return result, result.Error
}
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)
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")
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)
return result, result.Error
}
@@ -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)
return result, result.Error
}
@@ -211,17 +211,16 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
{"windowrules.kdl", ""},
}
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 {
// Skip if file already exists to preserve user modifications
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
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)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
@@ -239,7 +238,7 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
}
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)
return []DeploymentResult{mainResult}, mainResult.Error
}
@@ -255,14 +254,14 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05")
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)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0o644); err != nil {
if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
@@ -277,12 +276,12 @@ func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
}
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)
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)
return results, colorResult.Error
}
@@ -303,7 +302,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
}
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)
return []DeploymentResult{mainResult}, mainResult.Error
}
@@ -319,14 +318,14 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05")
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)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0o644); err != nil {
if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
@@ -340,7 +339,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
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)
return results, themeResult.Error
}
@@ -354,7 +353,7 @@ func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) {
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)
return results, tabsResult.Error
}
@@ -375,7 +374,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
}
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)
return []DeploymentResult{mainResult}, mainResult.Error
}
@@ -391,14 +390,14 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05")
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)
return []DeploymentResult{mainResult}, mainResult.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath))
}
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0o644); err != nil {
if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0644); err != nil {
mainResult.Error = fmt.Errorf("failed to write config: %w", err)
return []DeploymentResult{mainResult}, mainResult.Error
}
@@ -412,7 +411,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
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)
return results, themeResult.Error
}
@@ -439,7 +438,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
outputsContent.WriteString(output)
outputsContent.WriteString("\n\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); 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")
@@ -480,13 +479,13 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
}
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)
return result, result.Error
}
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)
return result, result.Error
}
@@ -504,7 +503,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
timestamp := time.Now().Format("2006-01-02_15-04-05")
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)
return result, result.Error
}
@@ -539,7 +538,7 @@ 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)
return result, result.Error
}
@@ -564,17 +563,15 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
{"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 {
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
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)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
@@ -598,7 +595,7 @@ func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig
outputsContent.WriteString(monitor)
outputsContent.WriteString("\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); 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")

View File

@@ -220,9 +220,9 @@ func TestConfigDeploymentFlow(t *testing.T) {
t.Run("deploy ghostty config with existing file", func(t *testing.T) {
existingContent := "# Old config\nfont-size = 14\n"
ghosttyPath := getGhosttyPath()
err := os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
err := os.MkdirAll(filepath.Dir(ghosttyPath), 0755)
require.NoError(t, err)
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0o644)
err = os.WriteFile(ghosttyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployGhosttyConfig()
@@ -422,9 +422,9 @@ general {
}
`
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)
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
err = os.WriteFile(hyprPath, []byte(existingContent), 0644)
require.NoError(t, err)
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
@@ -600,9 +600,9 @@ func TestAlacrittyConfigDeployment(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"
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)
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0o644)
err = os.WriteFile(alacrittyPath, []byte(existingContent), 0644)
require.NoError(t, err)
results, err := cd.deployAlacrittyConfig()

View File

@@ -38,7 +38,6 @@ 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
@@ -92,9 +91,6 @@ 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

View File

@@ -81,6 +81,7 @@ master {
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
vrr = 1
}
# ==================

View File

@@ -76,7 +76,6 @@ binds {
Mod+Shift+T { toggle-window-floating; }
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
Mod+W { toggle-column-tabbed-display; }
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
// === Focus Navigation ===
Mod+Left { focus-column-left; }
@@ -134,11 +133,6 @@ binds {
Mod+Ctrl+U { move-column-to-workspace-down; }
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 ===
Mod+Shift+Page_Down { move-workspace-down; }
Mod+Shift+Page_Up { move-workspace-up; }

View File

@@ -199,6 +199,31 @@ func labToHex(L, a, b float64) string {
return fmt.Sprintf("#%02x%02x%02x", r, g, b2)
}
// Adjust brightness while keeping the same hue
func retoneToL(hex string, Ltarget float64) string {
rgb := HexToRGB(hex)
col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B}
L, a, b := col.Lab()
L100 := L * 100.0
scale := 1.0
if L100 != 0 {
scale = Ltarget / L100
}
a2, b2 := a*scale, b*scale
// Don't let it get too saturated
maxChroma := 0.4
if math.Hypot(a2, b2) > maxChroma {
k := maxChroma / math.Hypot(a2, b2)
a2 *= k
b2 *= k
}
return labToHex(Ltarget, a2, b2)
}
func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 {
Lf := getLstar(hexFg)
Lb := getLstar(hexBg)
@@ -331,59 +356,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
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 {
IsLight bool
Background string
@@ -397,29 +369,6 @@ func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOpti
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 {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
@@ -440,9 +389,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
pr := HexToRGB(primaryColor)
ph := RGBToHSV(pr)
var palette Palette
var normalTextTarget, secondaryTarget float64
@@ -464,136 +410,115 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
}
palette.Color0 = NewColorInfo(bgColor)
baseSat := math.Max(ph.S, 0.5)
baseVal := math.Max(ph.V, 0.5)
hueShift := (hsv.H - 0.6) * 0.12
satBoost := 1.15
redH := blendHue(0.0, ph.H, 0.12)
greenH := blendHue(0.33, ph.H, 0.10)
yellowH := blendHue(0.14, ph.H, 0.04)
redH := math.Mod(0.0+hueShift+1.0, 1.0)
var redColor string
if opts.IsLight {
redColor = RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.80*satBoost, 1.0), V: 0.55}))
palette.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 {
redS := math.Min(baseSat*1.2, 1.0)
redV := baseVal * 0.95
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})))
palette.Color7 = NewColorInfo("#1a1a1a")
palette.Color8 = NewColorInfo("#2e2e2e")
} else {
redS := math.Min(baseSat*1.1, 1.0)
redV := math.Min(baseVal*1.15, 1.0)
palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts))
palette.Color7 = NewColorInfo("#abb2bf")
palette.Color8 = NewColorInfo("#5c6370")
}
greenS := math.Min(baseSat*1.0, 1.0)
greenV := math.Min(baseVal*1.0, 1.0)
palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts))
if opts.IsLight {
brightRed := RGBToHex(HSVToRGB(HSV{H: redH, S: math.Min(0.70*satBoost, 1.0), V: 0.65}))
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)
yellowV := math.Min(baseVal*1.25, 1.0)
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
// 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))
if opts.IsLight {
palette.Color15 = NewColorInfo("#1a1a1a")
} else {
palette.Color15 = NewColorInfo("#ffffff")
}
return palette

View File

@@ -366,19 +366,10 @@ func TestGeneratePalette(t *testing.T) {
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
// and has appropriate luminance for the mode (now theme-tinted, not pure white/black)
color15Lum := Luminance(result.Color15.Hex)
if tt.opts.IsLight {
// 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)
}
if tt.opts.IsLight && result.Color15.Hex != "#1a1a1a" {
t.Errorf("Light mode foreground = %s, expected #1a1a1a", result.Color15.Hex)
} else if !tt.opts.IsLight && result.Color15.Hex != "#ffffff" {
t.Errorf("Dark mode foreground = %s, expected #ffffff", result.Color15.Hex)
}
})
}
@@ -588,10 +579,6 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
bgColor := result.Color0.Hex
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)
minLc := 30.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 {
return NewArchDistribution(config, logChan)
})
Register("XeroLinux", "#888fe2", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution {
return NewArchDistribution(config, logChan)
})
}
type ArchDistribution struct {

View File

@@ -534,7 +534,7 @@ func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error
}
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)
}
@@ -555,7 +555,7 @@ TERMINAL=%s
`, terminalCmd)
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)
}
@@ -594,7 +594,7 @@ func (b *BaseDistribution) WriteHyprlandSessionTarget() error {
}
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)
}
@@ -605,7 +605,7 @@ Requires=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)
}

View File

@@ -43,7 +43,7 @@ func TestBaseDistribution_detectDMS_Installed(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
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()
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, "commit", "-m", "initial").Run()
@@ -87,7 +87,7 @@ func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
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()
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, "commit", "-m", "initial").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) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)

View File

@@ -540,12 +540,12 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
}
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)
}
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)
}
defer os.RemoveAll(tmpDir)
@@ -576,7 +576,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
}
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)
}

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 {
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))
continue
}
@@ -295,7 +295,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
for _, dir := range configDirs {
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))
continue
}
@@ -355,14 +355,14 @@ func SyncDMSConfigs(dmsPath string, logFunc func(string), sudoPassword string) e
for _, link := range symlinks {
sourceDir := filepath.Dir(link.source)
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))
continue
}
}
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))
continue
}
@@ -455,7 +455,7 @@ user = "greeter"
newConfig := strings.Join(finalLines, "\n")
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)
}

View File

@@ -79,16 +79,16 @@ func TestFindJSONFiles(t *testing.T) {
txtFile := filepath.Join(tmpDir, "readme.txt")
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)
}
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)
}
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)
}
if err := os.MkdirAll(subdir, 0o755); err != nil {
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
@@ -143,10 +143,10 @@ func TestFindJSONFilesMultiplePaths(t *testing.T) {
file1 := filepath.Join(tmpDir1, "app1.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)
}
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)
}
@@ -174,7 +174,7 @@ func TestAutoDiscoverProviders(t *testing.T) {
}`
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)
}
@@ -226,7 +226,7 @@ func TestAutoDiscoverProvidersNoFactory(t *testing.T) {
tmpDir := t.TempDir()
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)
}

View File

@@ -216,7 +216,7 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
overridePath := h.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)
}
@@ -398,7 +398,7 @@ func (h *HyprlandProvider) getBindSortPriority(action string) int {
func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error {
overridePath := h.GetOverridePath()
content := h.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0o644)
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string {

View File

@@ -187,7 +187,7 @@ bind = SUPER, right, movefocus, r
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)
}
@@ -245,7 +245,7 @@ bind = SUPER, B, exec, app2
#/# = 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)
}
@@ -278,10 +278,10 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) {
content1 := "bind = SUPER, Q, killactive\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)
}
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)
}
@@ -328,13 +328,13 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
}
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")
}
defer os.RemoveAll(tmpSubdir)
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)
}
@@ -381,7 +381,7 @@ bind = SUPER, Q, killactive
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)
}

View File

@@ -57,7 +57,7 @@ bind = SUPER, T, exec, kitty # Terminal
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)
}
@@ -134,7 +134,7 @@ func TestFormatKey(t *testing.T) {
for _, tt := range tests {
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)
}
@@ -189,7 +189,7 @@ func TestDescriptionFallback(t *testing.T) {
for _, tt := range tests {
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)
}

View File

@@ -218,7 +218,7 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
overridePath := m.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)
}
@@ -360,7 +360,7 @@ func (m *MangoWCProvider) getBindSortPriority(action string) int {
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0o644)
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {

View File

@@ -502,17 +502,17 @@ func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKe
p.dmsProcessed = true
}
expanded, err := utils.ExpandPath(sourcePath)
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
return
}
fullPath := expanded
if !filepath.IsAbs(expanded) {
fullPath = filepath.Join(baseDir, expanded)
}
includedBinds, err := p.parseFileWithSource(fullPath)
includedBinds, err := p.parseFileWithSource(expanded)
if err != nil {
return
}
@@ -521,10 +521,33 @@ func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKe
}
func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding {
keybinds, err := p.parseFileWithSource(dmsBindsPath)
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return nil
}
prevSource := p.currentSource
p.currentSource = dmsBindsPath
var keybinds []MangoWCKeyBinding
lines := strings.Split(string(data), "\n")
for lineNum, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.getKeybindAtLineContent(line, lineNum)
if kb == nil {
continue
}
kb.Source = dmsBindsPath
p.addBind(kb)
keybinds = append(keybinds, *kb)
}
p.currentSource = prevSource
p.dmsProcessed = true
return keybinds
}

View File

@@ -238,7 +238,7 @@ bind=Ctrl,1,view,1,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)
}
@@ -276,10 +276,10 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) {
content1 := "bind=ALT,q,killclient,\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)
}
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)
}
@@ -300,7 +300,7 @@ func TestMangoWCReadContentSingleFile(t *testing.T) {
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)
}
@@ -347,13 +347,13 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
}
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")
}
defer os.RemoveAll(tmpSubdir)
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)
}
@@ -384,7 +384,7 @@ bind=ALT,q,killclient,
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)
}
@@ -458,7 +458,7 @@ bind=Ctrl,2,view,2,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)
}

View File

@@ -223,7 +223,7 @@ bind=SUPER,n,switch_layout
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)
}
@@ -285,7 +285,7 @@ bind=ALT,Left,focusdir,left
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)
}

View File

@@ -235,7 +235,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
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)
}
@@ -485,7 +485,7 @@ func (n *NiriProvider) writeOverrideBinds(binds map[string]*overrideBind) error
return err
}
return os.WriteFile(overridePath, []byte(content), 0o644)
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (n *NiriProvider) getBindSortPriority(action string) int {

View File

@@ -53,7 +53,7 @@ func TestNiriParseBasicBinds(t *testing.T) {
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)
}
@@ -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)
}
@@ -148,7 +148,7 @@ func TestNiriParseRecentWindows(t *testing.T) {
func TestNiriParseInclude(t *testing.T) {
tmpDir := t.TempDir()
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)
}
@@ -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)
}
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)
}
@@ -185,7 +185,7 @@ include "dms/binds.kdl"
func TestNiriParseIncludeOverride(t *testing.T) {
tmpDir := t.TempDir()
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)
}
@@ -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)
}
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)
}
@@ -246,10 +246,10 @@ include "other.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)
}
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)
}
@@ -272,7 +272,7 @@ func TestNiriParseMissingInclude(t *testing.T) {
}
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)
}
@@ -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)
}
@@ -348,7 +348,7 @@ func TestNiriBindOverrideBehavior(t *testing.T) {
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)
}
@@ -386,7 +386,7 @@ func TestNiriBindOverrideBehavior(t *testing.T) {
func TestNiriBindOverrideWithIncludes(t *testing.T) {
tmpDir := t.TempDir()
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)
}
@@ -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)
}
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)
}
@@ -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)
}
@@ -508,7 +508,7 @@ func TestNiriParseNumericWorkspaceBinds(t *testing.T) {
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)
}
@@ -550,7 +550,7 @@ func TestNiriParseQuotedStringArgs(t *testing.T) {
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)
}
@@ -586,7 +586,7 @@ func TestNiriParseActionWithProperties(t *testing.T) {
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)
}

View File

@@ -27,7 +27,7 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
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)
}
@@ -312,7 +312,7 @@ func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
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)
}
@@ -351,12 +351,12 @@ func TestNiriEmptyArgsPreservation(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
if err := os.MkdirAll(dmsDir, 0755); 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 {
if err := os.WriteFile(bindsFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write binds file: %v", err)
}
@@ -428,7 +428,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)
}
@@ -621,7 +621,7 @@ func TestNiriGenerateWorkspaceBindsRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
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)
}

View File

@@ -200,7 +200,7 @@ bindsym $mod+t exec $term
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)
}
@@ -247,7 +247,7 @@ bindsym $mod+Right focus right
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)
}
@@ -328,13 +328,13 @@ func TestSwayReadContentWithTildeExpansion(t *testing.T) {
}
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")
}
defer os.RemoveAll(tmpSubdir)
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)
}
@@ -365,7 +365,7 @@ bindsym Mod4+q kill
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)
}
@@ -404,7 +404,7 @@ bindsym $mod+2 workspace number 2
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)
}

View File

@@ -190,7 +190,7 @@ bindsym $mod+s layout stacking
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)
}
@@ -253,7 +253,7 @@ bindsym $mod+f fullscreen toggle
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)
}

View File

@@ -62,7 +62,6 @@ var templateRegistry = []TemplateDef{
{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 {
@@ -148,7 +147,7 @@ func Run(opts Options) error {
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)
}
@@ -414,7 +413,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
modified := strings.ReplaceAll(string(origData), ".default.", ".dark.")
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
}

View File

@@ -15,13 +15,13 @@ func TestAppendConfigBinaryExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}
testConfig := "test config content"
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)
}
@@ -58,13 +58,13 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}
testConfig := "test config content"
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)
}
@@ -99,13 +99,13 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}
testConfig := "zen config content"
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)
}
@@ -139,13 +139,13 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}
testConfig := "test config content"
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)
}
@@ -180,13 +180,13 @@ func TestAppendConfigBothExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}
testConfig := "zen config content"
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)
}
@@ -220,13 +220,13 @@ func TestAppendConfigNeitherExists(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}
testConfig := "test config content"
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)
}
@@ -261,13 +261,13 @@ func TestAppendConfigNoChecks(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}
testConfig := "always include"
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)
}
@@ -298,7 +298,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
shellDir := filepath.Join(tempDir, "shell")
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)
}

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)
}
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)
}
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)
}
@@ -168,7 +168,7 @@ func (m *Manager) Install(plugin Plugin) error {
metaPath := pluginPath + ".meta"
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil {
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0644); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
} else {

View File

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

View File

@@ -27,7 +27,6 @@ type Plugin struct {
Distro []string `json:"distro"`
Screenshot string `json:"screenshot,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
Featured bool `json:"featured,omitempty"`
}
type GitClient interface {
@@ -148,7 +147,7 @@ func (r *Registry) Update() error {
}
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)
}
@@ -163,7 +162,7 @@ func (r *Registry) Update() error {
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)
}

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) {
pluginsDir := filepath.Join(dir, "plugins")
err := fs.MkdirAll(pluginsDir, 0o755)
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
data, err := json.Marshal(plugin)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0o644)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0644)
require.NoError(t, err)
}
@@ -118,10 +118,10 @@ func TestLoadPlugins(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0o755)
err := fs.MkdirAll(pluginsDir, 0755)
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)
plugin := Plugin{
@@ -146,7 +146,7 @@ func TestLoadPlugins(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
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)
plugin := Plugin{
@@ -170,10 +170,10 @@ func TestLoadPlugins(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0o755)
err := fs.MkdirAll(pluginsDir, 0755)
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)
plugin := Plugin{
@@ -303,7 +303,7 @@ func TestUpdate(t *testing.T) {
Distro: []string{"any"},
}
err := fs.MkdirAll(tmpDir, 0o755)
err := fs.MkdirAll(tmpDir, 0755)
require.NoError(t, err)
pullCalled := false

View File

@@ -67,9 +67,6 @@ func FilterByCapability(capability string, plugins []Plugin) []Plugin {
func SortByFirstParty(plugins []Plugin) []Plugin {
sort.SliceStable(plugins, func(i, j int) bool {
if plugins[i].Featured != plugins[j].Featured {
return plugins[i].Featured
}
isFirstPartyI := strings.HasPrefix(plugins[i].Repo, "https://github.com/AvengeMedia")
isFirstPartyJ := strings.HasPrefix(plugins[j].Repo, "https://github.com/AvengeMedia")
if isFirstPartyI != isFirstPartyJ {

View File

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

View File

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

View File

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

View File

@@ -186,7 +186,7 @@ func (b *SysfsBackend) SetBrightnessWithExponent(id string, percent int, exponen
brightnessPath := filepath.Join(devicePath, "brightness")
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)
}

View File

@@ -15,13 +15,13 @@ func TestManager_SetBrightness_LogindSuccess(t *testing.T) {
tmpDir := t.TempDir()
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)
}
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)
}
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)
}
@@ -86,13 +86,13 @@ func TestManager_SetBrightness_LogindFailsFallbackToSysfs(t *testing.T) {
tmpDir := t.TempDir()
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)
}
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)
}
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)
}
@@ -158,13 +158,13 @@ func TestManager_SetBrightness_NoLogind(t *testing.T) {
tmpDir := t.TempDir()
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)
}
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)
}
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)
}
@@ -215,13 +215,13 @@ func TestManager_SetBrightness_LEDWithLogind(t *testing.T) {
tmpDir := t.TempDir()
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)
}
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)
}
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)
}

View File

@@ -136,26 +136,26 @@ func TestSysfsBackend_ScanDevices(t *testing.T) {
tmpDir := t.TempDir()
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)
}
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)
}
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)
}
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)
}
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)
}
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)
}

View File

@@ -13,13 +13,13 @@ func setupTestManager(t *testing.T) (*Manager, string) {
tmpDir := t.TempDir()
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)
}
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)
}
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)
}
@@ -152,7 +152,7 @@ func TestHandleEvent_ChangeAction(t *testing.T) {
um := &UdevMonitor{stop: make(chan struct{})}
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)
}
@@ -225,7 +225,7 @@ func TestHandleChange_InvalidBrightnessValue(t *testing.T) {
um := &UdevMonitor{stop: make(chan struct{})}
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)
}

View File

@@ -37,16 +37,6 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
handleSetConfig(conn, req, m)
case "clipboard.store":
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:
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
}
@@ -128,29 +118,11 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
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 {
models.RespondError(conn, req.ID, err.Error())
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"})
}
@@ -233,9 +205,6 @@ func handleSetConfig(conn net.Conn, req models.Request, m *Manager) {
if v, ok := models.Get[bool](req, "disabled"); ok {
cfg.Disabled = v
}
if v, ok := models.Get[float64](req, "maxPinned"); ok {
cfg.MaxPinned = int(v)
}
if err := m.SetConfig(cfg); err != nil {
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"})
}
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"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
@@ -20,10 +19,8 @@ import (
"hash/fnv"
"github.com/fsnotify/fsnotify"
"github.com/godbus/dbus/v5"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
bolt "go.etcd.io/bbolt"
@@ -107,7 +104,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, 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,
})
if err != nil {
@@ -319,13 +316,6 @@ func (m *Manager) readAndStore(r *os.File, 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{
Data: data,
MimeType: mimeType,
@@ -337,8 +327,6 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
switch {
case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default:
entry.Preview = m.textPreview(data)
}
@@ -401,11 +389,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
}
c := b.Cursor()
var count int
for k, v := c.Last(); k != nil; k, v = c.Prev() {
entry, err := decodeEntry(v)
if err == nil && entry.Pinned {
continue
}
for k, _ := c.Last(); k != nil; k, _ = c.Prev() {
if count < m.config.MaxHistory {
count++
continue
@@ -435,11 +419,6 @@ func encodeEntry(e Entry) ([]byte, error) {
buf.WriteByte(0)
}
binary.Write(buf, binary.BigEndian, e.Hash)
if e.Pinned {
buf.WriteByte(1)
} else {
buf.WriteByte(0)
}
return buf.Bytes(), nil
}
@@ -483,12 +462,6 @@ func decodeEntry(data []byte) (Entry, error) {
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
}
@@ -505,10 +478,10 @@ func computeHash(data []byte) uint64 {
}
func extractHash(data []byte) uint64 {
if len(data) < 9 {
if len(data) < 8 {
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 {
@@ -519,7 +492,6 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{
"text/uri-list",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
@@ -540,14 +512,8 @@ func (m *Manager) selectMimeType(mimes []string) string {
}
}
// Skip useless MIME types when falling back
for _, mime := range mimes {
switch mime {
case "application/vnd.portal.filetransfer":
continue
default:
return mime
}
if len(mimes) > 0 {
return mimes[0]
}
return ""
@@ -576,62 +542,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)
}
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 {
units := []string{"B", "KiB", "MiB"}
var i int
@@ -820,81 +730,24 @@ func (m *Manager) DeleteEntry(id uint64) error {
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() {
if m.db == nil {
return
}
// Delete only non-pinned entries
if err := m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
if err := tx.DeleteBucket([]byte("clipboard")); err != nil {
return err
}
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 nil
_, err := tx.CreateBucket([]byte("clipboard"))
return err
}); err != nil {
log.Errorf("Failed to clear clipboard history: %v", err)
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 {
log.Errorf("Failed to compact database: %v", err)
}
if err := m.compactDB(); err != nil {
log.Errorf("Failed to compact database: %v", err)
}
m.updateState()
@@ -907,23 +760,23 @@ func (m *Manager) compactDB() error {
tmpPath := m.dbPath + ".compact"
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 {
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)
}
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 {
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)
}
if err := bolt.Compact(dstDB, srcDB, 0); err != nil {
srcDB.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)
}
@@ -931,11 +784,11 @@ func (m *Manager) compactDB() error {
dstDB.Close()
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)
}
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 {
return fmt.Errorf("reopen: %w", err)
}
@@ -982,21 +835,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.sourceMutex.Lock()
m.sourceMimeTypes = []string{mimeType}
m.sourceMutex.Unlock()
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)
@@ -1117,10 +960,6 @@ func (m *Manager) clearOldEntries(days int) error {
if err != nil {
continue
}
// Skip pinned entries
if entry.Pinned {
continue
}
if entry.Timestamp.Before(cutoff) {
toDelete = append(toDelete, k)
}
@@ -1398,8 +1237,6 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
switch {
case entry.IsImage:
entry.Preview = m.imagePreview(data, mimeType)
case mimeType == "text/uri-list":
entry.Preview, entry.IsImage = m.uriListPreview(data)
default:
entry.Preview = m.textPreview(data)
}
@@ -1413,390 +1250,3 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
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"
"time"
"github.com/godbus/dbus/v5"
bolt "go.etcd.io/bbolt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
@@ -20,7 +19,6 @@ type Config struct {
AutoClearDays int `json:"autoClearDays"`
ClearAtStartup bool `json:"clearAtStartup"`
Disabled bool `json:"disabled"`
MaxPinned int `json:"maxPinned"`
}
func DefaultConfig() Config {
@@ -29,7 +27,6 @@ func DefaultConfig() Config {
MaxEntrySize: 5 * 1024 * 1024,
AutoClearDays: 0,
ClearAtStartup: false,
MaxPinned: 25,
}
}
@@ -66,7 +63,7 @@ func SaveConfig(cfg Config) error {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
@@ -75,7 +72,7 @@ func SaveConfig(cfg Config) error {
return err
}
return os.WriteFile(path, data, 0o644)
return os.WriteFile(path, data, 0644)
}
type SearchParams struct {
@@ -103,7 +100,6 @@ type Entry struct {
Timestamp time.Time `json:"timestamp"`
IsImage bool `json:"isImage"`
Hash uint64 `json:"hash,omitempty"`
Pinned bool `json:"pinned"`
}
type State struct {
@@ -158,8 +154,6 @@ type Manager struct {
dirty chan struct{}
notifierWg sync.WaitGroup
lastState *State
dbusConn *dbus.Conn
}
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"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/godbus/dbus/v5"
)
@@ -111,17 +110,61 @@ func (m *Manager) updateAccountsState() error {
m.stateMutex.Lock()
defer m.stateMutex.Unlock()
m.state.Accounts.IconFile = dbusutil.GetOr(props, "IconFile", "")
m.state.Accounts.RealName = dbusutil.GetOr(props, "RealName", "")
m.state.Accounts.UserName = dbusutil.GetOr(props, "UserName", "")
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", "")
m.state.Accounts.Email = dbusutil.GetOr(props, "Email", "")
m.state.Accounts.Language = dbusutil.GetOr(props, "Language", "")
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["IconFile"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.IconFile = val
}
}
if v, ok := props["RealName"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.RealName = val
}
}
if v, ok := props["UserName"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.UserName = val
}
}
if v, ok := props["AccountType"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.AccountType = val
}
}
if v, ok := props["HomeDirectory"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.HomeDirectory = val
}
}
if v, ok := props["Shell"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Shell = val
}
}
if v, ok := props["Email"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Email = val
}
}
if v, ok := props["Language"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Language = val
}
}
if v, ok := props["Location"]; ok {
if val, ok := v.Value().(string); ok {
m.state.Accounts.Location = val
}
}
if v, ok := props["Locked"]; ok {
if val, ok := v.Value().(bool); ok {
m.state.Accounts.Locked = val
}
}
if v, ok := props["PasswordMode"]; ok {
if val, ok := v.Value().(int32); ok {
m.state.Accounts.PasswordMode = val
}
}
return nil
}
@@ -137,7 +180,7 @@ func (m *Manager) updateSettingsState() error {
return err
}
if colorScheme, ok := dbusutil.As[uint32](variant); ok {
if colorScheme, ok := variant.Value().(uint32); ok {
m.stateMutex.Lock()
m.state.Settings.ColorScheme = colorScheme
m.stateMutex.Unlock()

View File

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

View File

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

View File

@@ -150,13 +150,21 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int
}
if err := exec.Command("nmcli", "con", "mod", connName,
"connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority),
"ipv4.route-metric", fmt.Sprintf("%d", routeMetric),
"ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set priority for %s: %v", connName, err)
"connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority)).Run(); err != nil {
log.Warnf("Failed to set autoconnect-priority for %v: %v", connName, err)
continue
}
if err := exec.Command("nmcli", "con", "mod", connName,
"ipv4.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set ipv4.route-metric for %v: %v", connName, err)
}
if err := exec.Command("nmcli", "con", "mod", connName,
"ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set ipv6.route-metric for %v: %v", connName, err)
}
log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric)
}

View File

@@ -44,7 +44,6 @@ func HandleList(conn net.Conn, req models.Request) {
Dependencies: p.Dependencies,
Installed: installed,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
Featured: p.Featured,
RequiresDMS: p.RequiresDMS,
}
}

View File

@@ -13,7 +13,6 @@ type PluginInfo struct {
Dependencies []string `json:"dependencies,omitempty"`
Installed bool `json:"installed,omitempty"`
FirstParty bool `json:"firstParty,omitempty"`
Featured bool `json:"featured,omitempty"`
Note string `json:"note,omitempty"`
HasUpdate bool `json:"hasUpdate,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`

View File

@@ -10,7 +10,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
@@ -19,7 +18,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -45,15 +43,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "theme.auto.") {
if themeModeManager == nil {
models.RespondError(conn, req.ID, "theme mode manager not initialized")
return
}
thememode.HandleRequest(conn, req, themeModeManager)
return
}
if strings.HasPrefix(req.Method, "loginctl.") {
if loginctlManager == nil {
models.RespondError(conn, req.ID, "loginctl manager not initialized")
@@ -165,15 +154,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "dbus.") {
if dbusManager == nil {
models.RespondError(conn, req.ID, "dbus manager not initialized")
return
}
serverDbus.HandleRequest(conn, req, dbusManager, dbusClientID)
return
}
if strings.HasPrefix(req.Method, "clipboard.") {
switch req.Method {
case "clipboard.getConfig":

View File

@@ -20,7 +20,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
@@ -28,7 +27,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -67,11 +65,7 @@ var brightnessManager *brightness.Manager
var wlrOutputManager *wlroutput.Manager
var evdevManager *evdev.Manager
var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager
const dbusClientID = "dms-dbus-client"
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
var cupsSubscribers syncmap.Map[string, bool]
@@ -369,27 +363,6 @@ func InitializeClipboardManager() error {
return nil
}
func InitializeDbusManager() error {
manager, err := serverDbus.NewManager()
if err != nil {
log.Warnf("Failed to initialize dbus manager: %v", err)
return err
}
dbusManager = manager
log.Info("DBus manager initialized")
return nil
}
func InitializeThemeModeManager() error {
manager := thememode.NewManager()
themeModeManager = manager
log.Info("Theme mode automation manager initialized")
return nil
}
func handleConnection(conn net.Conn) {
defer conn.Close()
@@ -467,14 +440,6 @@ func getCapabilities() Capabilities {
caps = append(caps, "clipboard")
}
if themeModeManager != nil {
caps = append(caps, "theme.auto")
}
if dbusManager != nil {
caps = append(caps, "dbus")
}
return Capabilities{Capabilities: caps}
}
@@ -533,14 +498,6 @@ func getServerInfo() ServerInfo {
caps = append(caps, "clipboard")
}
if themeModeManager != nil {
caps = append(caps, "theme.auto")
}
if dbusManager != nil {
caps = append(caps, "dbus")
}
return ServerInfo{
APIVersion: APIVersion,
CLIVersion: CLIVersion,
@@ -809,38 +766,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}()
}
if shouldSubscribe("theme.auto") && themeModeManager != nil {
wg.Add(1)
themeAutoChan := themeModeManager.Subscribe(clientID + "-theme-auto")
go func() {
defer wg.Done()
defer themeModeManager.Unsubscribe(clientID + "-theme-auto")
initialState := themeModeManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "theme.auto", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-themeAutoChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "theme.auto", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("bluetooth") && bluezManager != nil {
wg.Add(1)
bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
@@ -1208,31 +1133,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}()
}
if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
go func() {
defer wg.Done()
defer dbusManager.UnsubscribeSignals(dbusClientID)
for {
select {
case event, ok := <-dbusChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "dbus", Data: event}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
go func() {
wg.Wait()
close(eventChan)
@@ -1298,12 +1198,6 @@ func cleanupManagers() {
if clipboardManager != nil {
clipboardManager.Close()
}
if dbusManager != nil {
dbusManager.Close()
}
if themeModeManager != nil {
themeModeManager.Close()
}
if wlContext != nil {
wlContext.Close()
}
@@ -1399,15 +1293,6 @@ func Start(printDocs bool) error {
log.Info(" wayland.gamma.setGamma - Set gamma value (params: gamma)")
log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)")
log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)")
log.Info("Theme automation:")
log.Info(" theme.auto.getState - Get current theme automation state")
log.Info(" theme.auto.setEnabled - Enable/disable theme automation (params: enabled)")
log.Info(" theme.auto.setMode - Set automation mode (params: mode [time|location])")
log.Info(" theme.auto.setSchedule - Set time schedule (params: startHour, startMinute, endHour, endMinute)")
log.Info(" theme.auto.setLocation - Set location (params: latitude, longitude)")
log.Info(" theme.auto.setUseIPLocation - Use IP location (params: use)")
log.Info(" theme.auto.trigger - Trigger immediate re-evaluation")
log.Info(" theme.auto.subscribe - Subscribe to theme automation state changes (streaming)")
log.Info("Bluetooth:")
log.Info(" bluetooth.getState - Get current bluetooth state")
log.Info(" bluetooth.startDiscovery - Start device discovery")
@@ -1565,12 +1450,6 @@ func Start(printDocs bool) error {
log.Debugf("WlrOutput manager unavailable: %v", err)
}
if err := InitializeThemeModeManager(); err != nil {
log.Warnf("Theme mode manager unavailable: %v", err)
} else {
notifyCapabilityChange()
}
fatalErrChan := make(chan error, 1)
if wlrOutputManager != nil {
go func() {
@@ -1611,14 +1490,6 @@ func Start(printDocs bool) error {
}
}()
go func() {
if err := InitializeDbusManager(); err != nil {
log.Warnf("DBus manager unavailable: %v", err)
} else {
notifyCapabilityChange()
}
}()
log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)

View File

@@ -163,11 +163,11 @@ func TestCleanupStaleSockets(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", tempDir)
staleSocket := filepath.Join(tempDir, "danklinux-999999.sock")
err := os.WriteFile(staleSocket, []byte{}, 0o600)
err := os.WriteFile(staleSocket, []byte{}, 0600)
require.NoError(t, err)
activeSocket := filepath.Join(tempDir, fmt.Sprintf("danklinux-%d.sock", os.Getpid()))
err = os.WriteFile(activeSocket, []byte{}, 0o600)
err = os.WriteFile(activeSocket, []byte{}, 0600)
require.NoError(t, err)
cleanupStaleSockets()

View File

@@ -1,154 +0,0 @@
package thememode
import (
"encoding/json"
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
if manager == nil {
models.RespondError(conn, req.ID, "theme mode manager not initialized")
return
}
switch req.Method {
case "theme.auto.getState":
handleGetState(conn, req, manager)
case "theme.auto.setEnabled":
handleSetEnabled(conn, req, manager)
case "theme.auto.setMode":
handleSetMode(conn, req, manager)
case "theme.auto.setSchedule":
handleSetSchedule(conn, req, manager)
case "theme.auto.setLocation":
handleSetLocation(conn, req, manager)
case "theme.auto.setUseIPLocation":
handleSetUseIPLocation(conn, req, manager)
case "theme.auto.trigger":
handleTrigger(conn, req, manager)
case "theme.auto.subscribe":
handleSubscribe(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
models.Respond(conn, req.ID, manager.GetState())
}
func handleSetEnabled(conn net.Conn, req models.Request, manager *Manager) {
enabled, err := params.Bool(req.Params, "enabled")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
manager.SetEnabled(enabled)
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto enabled set"})
}
func handleSetMode(conn net.Conn, req models.Request, manager *Manager) {
mode, err := params.String(req.Params, "mode")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if mode != "time" && mode != "location" {
models.RespondError(conn, req.ID, "invalid mode")
return
}
manager.SetMode(mode)
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto mode set"})
}
func handleSetSchedule(conn net.Conn, req models.Request, manager *Manager) {
startHour, err := params.Int(req.Params, "startHour")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
startMinute, err := params.Int(req.Params, "startMinute")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
endHour, err := params.Int(req.Params, "endHour")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
endMinute, err := params.Int(req.Params, "endMinute")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := manager.ValidateSchedule(startHour, startMinute, endHour, endMinute); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
manager.SetSchedule(startHour, startMinute, endHour, endMinute)
models.Respond(conn, req.ID, manager.GetState())
}
func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) {
lat, err := params.Float(req.Params, "latitude")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
lon, err := params.Float(req.Params, "longitude")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
manager.SetLocation(lat, lon)
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto location set"})
}
func handleSetUseIPLocation(conn net.Conn, req models.Request, manager *Manager) {
use, err := params.Bool(req.Params, "use")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
manager.SetUseIPLocation(use)
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto IP location set"})
}
func handleTrigger(conn net.Conn, req models.Request, manager *Manager) {
manager.TriggerUpdate()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto update triggered"})
}
func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
clientID := fmt.Sprintf("client-%p", conn)
stateChan := manager.Subscribe(clientID)
defer manager.Unsubscribe(clientID)
initialState := manager.GetState()
if err := json.NewEncoder(conn).Encode(models.Response[State]{
ID: req.ID,
Result: &initialState,
}); err != nil {
return
}
for state := range stateChan {
if err := json.NewEncoder(conn).Encode(models.Response[State]{
Result: &state,
}); err != nil {
return
}
}
}

View File

@@ -1,432 +0,0 @@
package thememode
import (
"errors"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
const (
defaultStartHour = 18
defaultStartMinute = 0
defaultEndHour = 6
defaultEndMinute = 0
defaultElevationTwilight = -6.0
defaultElevationDaylight = 3.0
)
type Manager struct {
config Config
configMutex sync.RWMutex
state *State
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan State]
locationMutex sync.RWMutex
cachedIPLat *float64
cachedIPLon *float64
stopChan chan struct{}
updateTrigger chan struct{}
wg sync.WaitGroup
}
func NewManager() *Manager {
m := &Manager{
config: Config{
Enabled: false,
Mode: "time",
StartHour: defaultStartHour,
StartMinute: defaultStartMinute,
EndHour: defaultEndHour,
EndMinute: defaultEndMinute,
ElevationTwilight: defaultElevationTwilight,
ElevationDaylight: defaultElevationDaylight,
},
stopChan: make(chan struct{}),
updateTrigger: make(chan struct{}, 1),
}
m.updateState(time.Now())
m.wg.Add(1)
go m.schedulerLoop()
return m
}
func (m *Manager) GetState() State {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return State{Config: m.getConfig()}
}
stateCopy := *m.state
return stateCopy
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) SetEnabled(enabled bool) {
m.configMutex.Lock()
if m.config.Enabled == enabled {
m.configMutex.Unlock()
return
}
m.config.Enabled = enabled
m.configMutex.Unlock()
m.TriggerUpdate()
}
func (m *Manager) SetMode(mode string) {
m.configMutex.Lock()
if m.config.Mode == mode {
m.configMutex.Unlock()
return
}
m.config.Mode = mode
m.configMutex.Unlock()
m.TriggerUpdate()
}
func (m *Manager) SetSchedule(startHour, startMinute, endHour, endMinute int) {
m.configMutex.Lock()
changed := m.config.StartHour != startHour ||
m.config.StartMinute != startMinute ||
m.config.EndHour != endHour ||
m.config.EndMinute != endMinute
if !changed {
m.configMutex.Unlock()
return
}
m.config.StartHour = startHour
m.config.StartMinute = startMinute
m.config.EndHour = endHour
m.config.EndMinute = endMinute
m.configMutex.Unlock()
m.TriggerUpdate()
}
func (m *Manager) SetLocation(lat, lon float64) {
m.configMutex.Lock()
if m.config.Latitude != nil && m.config.Longitude != nil &&
*m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation {
m.configMutex.Unlock()
return
}
m.config.Latitude = &lat
m.config.Longitude = &lon
m.config.UseIPLocation = false
m.configMutex.Unlock()
m.locationMutex.Lock()
m.cachedIPLat = nil
m.cachedIPLon = nil
m.locationMutex.Unlock()
m.TriggerUpdate()
}
func (m *Manager) SetUseIPLocation(use bool) {
m.configMutex.Lock()
if m.config.UseIPLocation == use {
m.configMutex.Unlock()
return
}
m.config.UseIPLocation = use
if use {
m.config.Latitude = nil
m.config.Longitude = nil
}
m.configMutex.Unlock()
if use {
m.locationMutex.Lock()
m.cachedIPLat = nil
m.cachedIPLon = nil
m.locationMutex.Unlock()
}
m.TriggerUpdate()
}
func (m *Manager) TriggerUpdate() {
select {
case m.updateTrigger <- struct{}{}:
default:
}
}
func (m *Manager) Close() {
select {
case <-m.stopChan:
return
default:
close(m.stopChan)
}
m.wg.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
func (m *Manager) schedulerLoop() {
defer m.wg.Done()
var timer *time.Timer
for {
config := m.getConfig()
now := time.Now()
var isLight bool
var next time.Time
if config.Enabled {
isLight, next = m.computeSchedule(now, config)
} else {
m.stateMutex.RLock()
if m.state != nil {
isLight = m.state.IsLight
}
m.stateMutex.RUnlock()
next = now.Add(24 * time.Hour)
}
m.updateStateWithValues(config, isLight, next)
waitDur := time.Until(next)
if !config.Enabled {
waitDur = 24 * time.Hour
}
if waitDur < time.Second {
waitDur = time.Second
}
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(waitDur)
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.updateTrigger:
timer.Stop()
continue
case <-timer.C:
continue
}
}
}
func (m *Manager) updateState(now time.Time) {
config := m.getConfig()
var isLight bool
var next time.Time
if config.Enabled {
isLight, next = m.computeSchedule(now, config)
} else {
m.stateMutex.RLock()
if m.state != nil {
isLight = m.state.IsLight
}
m.stateMutex.RUnlock()
next = now.Add(24 * time.Hour)
}
m.updateStateWithValues(config, isLight, next)
}
func (m *Manager) updateStateWithValues(config Config, isLight bool, next time.Time) {
newState := State{
Config: config,
IsLight: isLight,
NextTransition: next,
}
m.stateMutex.Lock()
if m.state != nil && statesEqual(m.state, &newState) {
m.stateMutex.Unlock()
return
}
m.state = &newState
m.stateMutex.Unlock()
m.notifySubscribers()
}
func (m *Manager) notifySubscribers() {
state := m.GetState()
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- state:
default:
}
return true
})
}
func (m *Manager) getConfig() Config {
m.configMutex.RLock()
defer m.configMutex.RUnlock()
return m.config
}
func (m *Manager) getLocation(config Config) (*float64, *float64) {
if config.Latitude != nil && config.Longitude != nil {
return config.Latitude, config.Longitude
}
if !config.UseIPLocation {
return nil, nil
}
m.locationMutex.RLock()
if m.cachedIPLat != nil && m.cachedIPLon != nil {
lat, lon := m.cachedIPLat, m.cachedIPLon
m.locationMutex.RUnlock()
return lat, lon
}
m.locationMutex.RUnlock()
lat, lon, err := wayland.FetchIPLocation()
if err != nil {
return nil, nil
}
m.locationMutex.Lock()
m.cachedIPLat = lat
m.cachedIPLon = lon
m.locationMutex.Unlock()
return lat, lon
}
func statesEqual(a, b *State) bool {
if a == nil || b == nil {
return a == b
}
if a.IsLight != b.IsLight || !a.NextTransition.Equal(b.NextTransition) {
return false
}
return a.Config == b.Config
}
func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) {
if config.Mode == "location" {
return m.computeLocationSchedule(now, config)
}
return computeTimeSchedule(now, config)
}
func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) {
startMinutes := config.StartHour*60 + config.StartMinute
endMinutes := config.EndHour*60 + config.EndMinute
currentMinutes := now.Hour()*60 + now.Minute()
startTime := time.Date(now.Year(), now.Month(), now.Day(), config.StartHour, config.StartMinute, 0, 0, now.Location())
endTime := time.Date(now.Year(), now.Month(), now.Day(), config.EndHour, config.EndMinute, 0, 0, now.Location())
if startMinutes == endMinutes {
next := startTime
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return true, next
}
if startMinutes < endMinutes {
if currentMinutes < startMinutes {
return true, startTime
}
if currentMinutes >= endMinutes {
return true, startTime.Add(24 * time.Hour)
}
return false, endTime
}
if currentMinutes >= startMinutes {
return false, endTime.Add(24 * time.Hour)
}
if currentMinutes < endMinutes {
return false, endTime
}
return true, startTime
}
func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, time.Time) {
lat, lon := m.getLocation(config)
if lat == nil || lon == nil {
currentIsLight := false
m.stateMutex.RLock()
if m.state != nil {
currentIsLight = m.state.IsLight
}
m.stateMutex.RUnlock()
return currentIsLight, now.Add(10 * time.Minute)
}
times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
if cond != wayland.SunNormal {
if cond == wayland.SunMidnightSun {
return true, startOfNextDay(now)
}
return false, startOfNextDay(now)
}
if now.Before(times.Sunrise) {
return false, times.Sunrise
}
if now.Before(times.Sunset) {
return true, times.Sunset
}
nextDay := startOfNextDay(now)
nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight)
if nextCond != wayland.SunNormal {
if nextCond == wayland.SunMidnightSun {
return true, startOfNextDay(nextDay)
}
return false, startOfNextDay(nextDay)
}
return false, nextTimes.Sunrise
}
func startOfNextDay(t time.Time) time.Time {
next := t.Add(24 * time.Hour)
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
}
func validateHourMinute(hour, minute int) bool {
if hour < 0 || hour > 23 {
return false
}
if minute < 0 || minute > 59 {
return false
}
return true
}
func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error {
if !validateHourMinute(startHour, startMinute) || !validateHourMinute(endHour, endMinute) {
return errInvalidTime
}
return nil
}
var errInvalidTime = errors.New("invalid schedule time")

View File

@@ -1,23 +0,0 @@
package thememode
import "time"
type Config struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
StartHour int `json:"startHour"`
StartMinute int `json:"startMinute"`
EndHour int `json:"endHour"`
EndMinute int `json:"endMinute"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
UseIPLocation bool `json:"useIPLocation"`
ElevationTwilight float64 `json:"elevationTwilight"`
ElevationDaylight float64 `json:"elevationDaylight"`
}
type State struct {
Config Config `json:"config"`
IsLight bool `json:"isLight"`
NextTransition time.Time `json:"nextTransition"`
}

View File

@@ -626,7 +626,6 @@ func (m *Manager) schedulerLoop() {
m.schedule.calcDay = time.Time{}
m.scheduleMutex.Unlock()
m.recalcSchedule(time.Now())
m.updateStateFromSchedule()
m.configMutex.RLock()
enabled := m.config.Enabled
m.configMutex.RUnlock()

View File

@@ -66,7 +66,7 @@ func (m *Manager) Install(theme Theme, registryThemeDir string) error {
return fmt.Errorf("theme already installed: %s", theme.Name)
}
if err := m.fs.MkdirAll(themeDir, 0o755); err != nil {
if err := m.fs.MkdirAll(themeDir, 0755); err != nil {
return fmt.Errorf("failed to create theme directory: %w", err)
}
@@ -76,7 +76,7 @@ func (m *Manager) Install(theme Theme, registryThemeDir string) error {
}
themePath := filepath.Join(themeDir, "theme.json")
if err := afero.WriteFile(m.fs, themePath, data, 0o644); err != nil {
if err := afero.WriteFile(m.fs, themePath, data, 0644); err != nil {
return fmt.Errorf("failed to write theme file: %w", err)
}
@@ -107,7 +107,7 @@ func (m *Manager) copyPreviewFiles(srcDir, dstDir string, theme Theme) {
continue
}
dstPath := filepath.Join(dstDir, preview)
_ = afero.WriteFile(m.fs, dstPath, data, 0o644)
_ = afero.WriteFile(m.fs, dstPath, data, 0644)
}
}
@@ -138,7 +138,7 @@ func (m *Manager) Update(theme Theme) error {
return fmt.Errorf("failed to marshal theme: %w", err)
}
if err := afero.WriteFile(m.fs, themePath, data, 0o644); err != nil {
if err := afero.WriteFile(m.fs, themePath, data, 0644); err != nil {
return fmt.Errorf("failed to write theme file: %w", err)
}

View File

@@ -182,7 +182,7 @@ func (r *Registry) Update() error {
}
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)
}
@@ -195,7 +195,7 @@ func (r *Registry) Update() error {
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)
}

View File

@@ -281,7 +281,7 @@ func (m Model) tryFingerprint() tea.Cmd {
askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano()))
scriptContent := "#!/bin/sh\nexit 1\n"
if err := os.WriteFile(askpassScript, []byte(scriptContent), 0o700); err != nil {
if err := os.WriteFile(askpassScript, []byte(scriptContent), 0700); err != nil {
return passwordValidMsg{password: "", valid: false}
}
defer os.Remove(askpassScript)

View File

@@ -144,7 +144,7 @@ func TestFlatpakExistsCommandFailure(t *testing.T) {
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0o755)
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
@@ -168,7 +168,7 @@ func TestFlatpakSearchBySubstringCommandFailure(t *testing.T) {
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0o755)
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
@@ -192,7 +192,7 @@ func TestFlatpakInstallationDirCommandFailure(t *testing.T) {
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0o755)
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
@@ -220,7 +220,7 @@ if [ "$1" = "info" ] && [ "$2" = "app.exists.test" ]; then
fi
exit 1
`
err := os.WriteFile(fakeFlatpak, []byte(script), 0o755)
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
@@ -239,7 +239,7 @@ func TestAnyFlatpakExistsNoneExist(t *testing.T) {
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0o755)
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}

View File

@@ -39,10 +39,10 @@ func TestGetDMSVersionInfo_Structure(t *testing.T) {
// Create a temp directory with a fake DMS installation
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(dmsPath, 0755)
// Create a .git directory to simulate git installation
os.MkdirAll(filepath.Join(dmsPath, ".git"), 0o755)
os.MkdirAll(filepath.Join(dmsPath, ".git"), 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
@@ -84,8 +84,8 @@ func TestGetDMSVersionInfo_Structure(t *testing.T) {
func TestGetDMSVersionInfo_BranchVersion(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(filepath.Join(dmsPath, ".git"), 0o755)
os.MkdirAll(dmsPath, 0755)
os.MkdirAll(filepath.Join(dmsPath, ".git"), 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
@@ -116,8 +116,8 @@ func TestGetDMSVersionInfo_BranchVersion(t *testing.T) {
func TestGetDMSVersionInfo_NoUpdate(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(filepath.Join(dmsPath, ".git"), 0o755)
os.MkdirAll(dmsPath, 0755)
os.MkdirAll(filepath.Join(dmsPath, ".git"), 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
@@ -157,7 +157,7 @@ func TestGetCurrentDMSVersion_GitTag(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
@@ -168,7 +168,7 @@ func TestGetCurrentDMSVersion_GitTag(t *testing.T) {
exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run()
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, "commit", "-m", "initial").Run()
exec.Command("git", "-C", dmsPath, "tag", "v0.1.0").Run()
@@ -190,7 +190,7 @@ func TestGetCurrentDMSVersion_GitBranch(t *testing.T) {
tempDir := t.TempDir()
dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms")
os.MkdirAll(dmsPath, 0o755)
os.MkdirAll(dmsPath, 0755)
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
@@ -202,7 +202,7 @@ func TestGetCurrentDMSVersion_GitBranch(t *testing.T) {
exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run()
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, "commit", "-m", "initial").Run()

View File

@@ -1,658 +0,0 @@
package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
type HyprlandWindowRule struct {
MatchClass string
MatchTitle string
MatchXWayland *bool
MatchFloating *bool
MatchFullscreen *bool
MatchPinned *bool
MatchInitialised *bool
Rule string
Value string
Source string
RawLine string
}
type HyprlandRulesParser struct {
configDir string
processedFiles map[string]bool
rules []HyprlandWindowRule
currentSource string
dmsRulesExists bool
dmsRulesIncluded bool
includeCount int
dmsIncludePos int
rulesAfterDMS int
dmsProcessed bool
}
func NewHyprlandRulesParser(configDir string) *HyprlandRulesParser {
return &HyprlandRulesParser{
configDir: configDir,
processedFiles: make(map[string]bool),
rules: []HyprlandWindowRule{},
dmsIncludePos: -1,
}
}
func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsRulesPath := filepath.Join(expandedDir, "dms", "windowrules.conf")
if _, err := os.Stat(dmsRulesPath); err == nil {
p.dmsRulesExists = true
}
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
if err := p.parseFile(mainConfig); err != nil {
return nil, err
}
if p.dmsRulesExists && !p.dmsProcessed {
p.parseDMSRulesDirectly(dmsRulesPath)
}
return p.rules, nil
}
func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) {
data, err := os.ReadFile(dmsRulesPath)
if err != nil {
return
}
prevSource := p.currentSource
p.currentSource = dmsRulesPath
lines := strings.Split(string(data), "\n")
for _, line := range lines {
p.parseLine(line)
}
p.currentSource = prevSource
p.dmsProcessed = true
}
func (p *HyprlandRulesParser) parseFile(filePath string) error {
absPath, err := filepath.Abs(filePath)
if err != nil {
return err
}
if p.processedFiles[absPath] {
return nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil
}
prevSource := p.currentSource
p.currentSource = absPath
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath))
continue
}
p.parseLine(line)
}
p.currentSource = prevSource
return nil
}
func (p *HyprlandRulesParser) handleSource(line string, baseDir string) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/windowrules.conf" || strings.HasSuffix(sourcePath, "/dms/windowrules.conf")
p.includeCount++
if isDMSSource {
p.dmsRulesIncluded = 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
}
_ = p.parseFile(expanded)
}
func (p *HyprlandRulesParser) parseLine(line string) {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "windowrule") {
rule := p.parseWindowRuleLine(trimmed)
if rule != nil {
rule.Source = p.currentSource
p.rules = append(p.rules, *rule)
}
}
}
var windowRuleV2Regex = regexp.MustCompile(`^windowrulev?2?\s*=\s*(.+)$`)
func (p *HyprlandRulesParser) parseWindowRuleLine(line string) *HyprlandWindowRule {
matches := windowRuleV2Regex.FindStringSubmatch(line)
if len(matches) < 2 {
return nil
}
content := strings.TrimSpace(matches[1])
isV2 := strings.HasPrefix(line, "windowrulev2")
rule := &HyprlandWindowRule{
RawLine: line,
}
if isV2 {
p.parseWindowRuleV2(content, rule)
} else {
p.parseWindowRuleV1(content, rule)
}
return rule
}
func (p *HyprlandRulesParser) parseWindowRuleV1(content string, rule *HyprlandWindowRule) {
parts := strings.SplitN(content, ",", 2)
if len(parts) < 2 {
return
}
rule.Rule = strings.TrimSpace(parts[0])
rule.MatchClass = strings.TrimSpace(parts[1])
}
func (p *HyprlandRulesParser) parseWindowRuleV2(content string, rule *HyprlandWindowRule) {
parts := strings.SplitN(content, ",", 2)
if len(parts) < 2 {
return
}
ruleAndValue := strings.TrimSpace(parts[0])
matchPart := strings.TrimSpace(parts[1])
if idx := strings.Index(ruleAndValue, " "); idx > 0 {
rule.Rule = ruleAndValue[:idx]
rule.Value = strings.TrimSpace(ruleAndValue[idx+1:])
} else {
rule.Rule = ruleAndValue
}
matchPairs := strings.Split(matchPart, ",")
for _, pair := range matchPairs {
pair = strings.TrimSpace(pair)
if colonIdx := strings.Index(pair, ":"); colonIdx > 0 {
key := strings.TrimSpace(pair[:colonIdx])
value := strings.TrimSpace(pair[colonIdx+1:])
switch key {
case "class":
rule.MatchClass = value
case "title":
rule.MatchTitle = value
case "xwayland":
b := value == "1" || value == "true"
rule.MatchXWayland = &b
case "floating":
b := value == "1" || value == "true"
rule.MatchFloating = &b
case "fullscreen":
b := value == "1" || value == "true"
rule.MatchFullscreen = &b
case "pinned":
b := value == "1" || value == "true"
rule.MatchPinned = &b
case "initialised", "initialized":
b := value == "1" || value == "true"
rule.MatchInitialised = &b
}
}
}
}
func (p *HyprlandRulesParser) HasDMSRulesIncluded() bool {
return p.dmsRulesIncluded
}
func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
status := &windowrules.DMSRulesStatus{
Exists: p.dmsRulesExists,
Included: p.dmsRulesIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS,
}
switch {
case !p.dmsRulesExists:
status.Effective = false
status.StatusMessage = "dms/windowrules.conf does not exist"
case !p.dmsRulesIncluded:
status.Effective = false
status.StatusMessage = "dms/windowrules.conf is not sourced in config"
case p.rulesAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.rulesAfterDMS
status.StatusMessage = "Some DMS rules may be overridden by config rules"
default:
status.Effective = true
status.StatusMessage = "DMS window rules are active"
}
return status
}
type HyprlandRulesParseResult struct {
Rules []HyprlandWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseHyprlandWindowRules(configDir string) (*HyprlandRulesParseResult, error) {
parser := NewHyprlandRulesParser(configDir)
rules, err := parser.Parse()
if err != nil {
return nil, err
}
return &HyprlandRulesParseResult{
Rules: rules,
DMSRulesIncluded: parser.HasDMSRulesIncluded(),
DMSStatus: parser.buildDMSStatus(),
}, nil
}
func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) {
t := true
switch rule {
case "float":
actions.OpenFloating = &t
case "tile":
actions.Tile = &t
case "fullscreen":
actions.OpenFullscreen = &t
case "maximize":
actions.OpenMaximized = &t
case "nofocus":
actions.NoFocus = &t
case "noborder":
actions.NoBorder = &t
case "noshadow":
actions.NoShadow = &t
case "nodim":
actions.NoDim = &t
case "noblur":
actions.NoBlur = &t
case "noanim":
actions.NoAnim = &t
case "norounding":
actions.NoRounding = &t
case "pin":
actions.Pin = &t
case "opaque":
actions.Opaque = &t
case "forcergbx":
actions.ForcergbX = &t
case "opacity":
if f, err := strconv.ParseFloat(value, 64); err == nil {
actions.Opacity = &f
}
case "size":
actions.Size = value
case "move":
actions.Move = value
case "monitor":
actions.Monitor = value
case "workspace":
actions.Workspace = value
case "idleinhibit":
actions.Idleinhibit = value
case "rounding":
if i, err := strconv.Atoi(value); err == nil {
actions.CornerRadius = &i
}
}
}
func ConvertHyprlandRulesToWindowRules(hyprRules []HyprlandWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(hyprRules))
for i, hr := range hyprRules {
wr := windowrules.WindowRule{
ID: strconv.Itoa(i),
Enabled: true,
Source: hr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: hr.MatchClass,
Title: hr.MatchTitle,
XWayland: hr.MatchXWayland,
IsFloating: hr.MatchFloating,
Fullscreen: hr.MatchFullscreen,
Pinned: hr.MatchPinned,
Initialised: hr.MatchInitialised,
},
}
applyHyprlandRuleAction(&wr.Actions, hr.Rule, hr.Value)
result = append(result, wr)
}
return result
}
type HyprlandWritableProvider struct {
configDir string
}
func NewHyprlandWritableProvider(configDir string) *HyprlandWritableProvider {
return &HyprlandWritableProvider{configDir: configDir}
}
func (p *HyprlandWritableProvider) Name() string {
return "hyprland"
}
func (p *HyprlandWritableProvider) GetOverridePath() string {
expanded, _ := utils.ExpandPath(p.configDir)
return filepath.Join(expanded, "dms", "windowrules.conf")
}
func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseHyprlandWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Hyprland Window Rules",
Provider: "hyprland",
Rules: ConvertHyprlandRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
}
found := false
for i, r := range rules {
if r.ID == rule.ID {
rules[i] = rule
found = true
break
}
}
if !found {
rules = append(rules, rule)
}
return p.writeDMSRules(rules)
}
func (p *HyprlandWritableProvider) RemoveRule(id string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
newRules := make([]windowrules.WindowRule, 0, len(rules))
for _, r := range rules {
if r.ID != id {
newRules = append(newRules, r)
}
}
return p.writeDMSRules(newRules)
}
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule)
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
rulesPath := p.GetOverridePath()
data, err := os.ReadFile(rulesPath)
if err != nil {
if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
}
return nil, err
}
var rules []windowrules.WindowRule
var currentID, currentName string
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if matches := dmsRuleCommentRegex.FindStringSubmatch(trimmed); matches != nil {
currentID = matches[1]
currentName = matches[2]
continue
}
if strings.HasPrefix(trimmed, "windowrulev2") {
parser := NewHyprlandRulesParser(p.configDir)
hrule := parser.parseWindowRuleLine(trimmed)
if hrule == nil {
continue
}
wr := windowrules.WindowRule{
ID: currentID,
Name: currentName,
Enabled: true,
Source: rulesPath,
MatchCriteria: windowrules.MatchCriteria{
AppID: hrule.MatchClass,
Title: hrule.MatchTitle,
XWayland: hrule.MatchXWayland,
IsFloating: hrule.MatchFloating,
Fullscreen: hrule.MatchFullscreen,
Pinned: hrule.MatchPinned,
Initialised: hrule.MatchInitialised,
},
}
applyHyprlandRuleAction(&wr.Actions, hrule.Rule, hrule.Value)
if wr.ID == "" {
wr.ID = hrule.MatchClass
if wr.ID == "" {
wr.ID = hrule.MatchTitle
}
}
rules = append(rules, wr)
currentID = ""
currentName = ""
}
}
return rules, nil
}
func (p *HyprlandWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
rulesPath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil {
return err
}
var lines []string
lines = append(lines, "# DMS Window Rules - Managed by DankMaterialShell")
lines = append(lines, "# Do not edit manually - changes may be overwritten")
lines = append(lines, "")
for _, rule := range rules {
lines = append(lines, p.formatRuleLines(rule)...)
}
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func (p *HyprlandWritableProvider) formatRuleLines(rule windowrules.WindowRule) []string {
var lines []string
lines = append(lines, fmt.Sprintf("# DMS-RULE: id=%s, name=%s", rule.ID, rule.Name))
var matchParts []string
if rule.MatchCriteria.AppID != "" {
matchParts = append(matchParts, fmt.Sprintf("class:%s", rule.MatchCriteria.AppID))
}
if rule.MatchCriteria.Title != "" {
matchParts = append(matchParts, fmt.Sprintf("title:%s", rule.MatchCriteria.Title))
}
if rule.MatchCriteria.XWayland != nil {
matchParts = append(matchParts, fmt.Sprintf("xwayland:%d", boolToInt(*rule.MatchCriteria.XWayland)))
}
if rule.MatchCriteria.IsFloating != nil {
matchParts = append(matchParts, fmt.Sprintf("floating:%d", boolToInt(*rule.MatchCriteria.IsFloating)))
}
if rule.MatchCriteria.Fullscreen != nil {
matchParts = append(matchParts, fmt.Sprintf("fullscreen:%d", boolToInt(*rule.MatchCriteria.Fullscreen)))
}
if rule.MatchCriteria.Pinned != nil {
matchParts = append(matchParts, fmt.Sprintf("pinned:%d", boolToInt(*rule.MatchCriteria.Pinned)))
}
matchStr := strings.Join(matchParts, ", ")
a := rule.Actions
if a.OpenFloating != nil && *a.OpenFloating {
lines = append(lines, fmt.Sprintf("windowrulev2 = float, %s", matchStr))
}
if a.Tile != nil && *a.Tile {
lines = append(lines, fmt.Sprintf("windowrulev2 = tile, %s", matchStr))
}
if a.OpenFullscreen != nil && *a.OpenFullscreen {
lines = append(lines, fmt.Sprintf("windowrulev2 = fullscreen, %s", matchStr))
}
if a.OpenMaximized != nil && *a.OpenMaximized {
lines = append(lines, fmt.Sprintf("windowrulev2 = maximize, %s", matchStr))
}
if a.NoFocus != nil && *a.NoFocus {
lines = append(lines, fmt.Sprintf("windowrulev2 = nofocus, %s", matchStr))
}
if a.NoBorder != nil && *a.NoBorder {
lines = append(lines, fmt.Sprintf("windowrulev2 = noborder, %s", matchStr))
}
if a.NoShadow != nil && *a.NoShadow {
lines = append(lines, fmt.Sprintf("windowrulev2 = noshadow, %s", matchStr))
}
if a.NoDim != nil && *a.NoDim {
lines = append(lines, fmt.Sprintf("windowrulev2 = nodim, %s", matchStr))
}
if a.NoBlur != nil && *a.NoBlur {
lines = append(lines, fmt.Sprintf("windowrulev2 = noblur, %s", matchStr))
}
if a.NoAnim != nil && *a.NoAnim {
lines = append(lines, fmt.Sprintf("windowrulev2 = noanim, %s", matchStr))
}
if a.NoRounding != nil && *a.NoRounding {
lines = append(lines, fmt.Sprintf("windowrulev2 = norounding, %s", matchStr))
}
if a.Pin != nil && *a.Pin {
lines = append(lines, fmt.Sprintf("windowrulev2 = pin, %s", matchStr))
}
if a.Opaque != nil && *a.Opaque {
lines = append(lines, fmt.Sprintf("windowrulev2 = opaque, %s", matchStr))
}
if a.ForcergbX != nil && *a.ForcergbX {
lines = append(lines, fmt.Sprintf("windowrulev2 = forcergbx, %s", matchStr))
}
if a.Opacity != nil {
lines = append(lines, fmt.Sprintf("windowrulev2 = opacity %.2f, %s", *a.Opacity, matchStr))
}
if a.Size != "" {
lines = append(lines, fmt.Sprintf("windowrulev2 = size %s, %s", a.Size, matchStr))
}
if a.Move != "" {
lines = append(lines, fmt.Sprintf("windowrulev2 = move %s, %s", a.Move, matchStr))
}
if a.Monitor != "" {
lines = append(lines, fmt.Sprintf("windowrulev2 = monitor %s, %s", a.Monitor, matchStr))
}
if a.Workspace != "" {
lines = append(lines, fmt.Sprintf("windowrulev2 = workspace %s, %s", a.Workspace, matchStr))
}
if a.CornerRadius != nil {
lines = append(lines, fmt.Sprintf("windowrulev2 = rounding %d, %s", *a.CornerRadius, matchStr))
}
if a.Idleinhibit != "" {
lines = append(lines, fmt.Sprintf("windowrulev2 = idleinhibit %s, %s", a.Idleinhibit, matchStr))
}
if len(lines) == 1 {
lines = append(lines, fmt.Sprintf("# (no actions defined for rule %s)", rule.ID))
}
lines = append(lines, "")
return lines
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@@ -1,280 +0,0 @@
package providers
import (
"os"
"path/filepath"
"testing"
)
func TestParseWindowRuleV1(t *testing.T) {
parser := NewHyprlandRulesParser("")
tests := []struct {
name string
line string
wantClass string
wantRule string
wantNil bool
}{
{
name: "basic float rule",
line: "windowrule = float, ^(firefox)$",
wantClass: "^(firefox)$",
wantRule: "float",
},
{
name: "tile rule",
line: "windowrule = tile, steam",
wantClass: "steam",
wantRule: "tile",
},
{
name: "no match returns empty class",
line: "windowrule = float",
wantClass: "",
wantRule: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parser.parseWindowRuleLine(tt.line)
if tt.wantNil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.MatchClass != tt.wantClass {
t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass)
}
if result.Rule != tt.wantRule {
t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule)
}
})
}
}
func TestParseWindowRuleV2(t *testing.T) {
parser := NewHyprlandRulesParser("")
tests := []struct {
name string
line string
wantClass string
wantTitle string
wantRule string
wantValue string
}{
{
name: "float with class",
line: "windowrulev2 = float, class:^(firefox)$",
wantClass: "^(firefox)$",
wantRule: "float",
},
{
name: "opacity with value",
line: "windowrulev2 = opacity 0.8, class:^(code)$",
wantClass: "^(code)$",
wantRule: "opacity",
wantValue: "0.8",
},
{
name: "size with value and title",
line: "windowrulev2 = size 800 600, class:^(steam)$, title:Settings",
wantClass: "^(steam)$",
wantTitle: "Settings",
wantRule: "size",
wantValue: "800 600",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parser.parseWindowRuleLine(tt.line)
if result == nil {
t.Fatal("expected non-nil result")
}
if result.MatchClass != tt.wantClass {
t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass)
}
if result.MatchTitle != tt.wantTitle {
t.Errorf("MatchTitle = %q, want %q", result.MatchTitle, tt.wantTitle)
}
if result.Rule != tt.wantRule {
t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule)
}
if result.Value != tt.wantValue {
t.Errorf("Value = %q, want %q", result.Value, tt.wantValue)
}
})
}
}
func TestConvertHyprlandRulesToWindowRules(t *testing.T) {
hyprRules := []HyprlandWindowRule{
{MatchClass: "^(firefox)$", Rule: "float"},
{MatchClass: "^(code)$", Rule: "opacity", Value: "0.9"},
{MatchClass: "^(steam)$", Rule: "maximize"},
}
result := ConvertHyprlandRulesToWindowRules(hyprRules)
if len(result) != 3 {
t.Errorf("expected 3 rules, got %d", len(result))
}
if result[0].MatchCriteria.AppID != "^(firefox)$" {
t.Errorf("rule 0 AppID = %q, want ^(firefox)$", result[0].MatchCriteria.AppID)
}
if result[0].Actions.OpenFloating == nil || !*result[0].Actions.OpenFloating {
t.Error("rule 0 should have OpenFloating = true")
}
if result[1].Actions.Opacity == nil || *result[1].Actions.Opacity != 0.9 {
t.Errorf("rule 1 Opacity = %v, want 0.9", result[1].Actions.Opacity)
}
if result[2].Actions.OpenMaximized == nil || !*result[2].Actions.OpenMaximized {
t.Error("rule 2 should have OpenMaximized = true")
}
}
func TestHyprlandWritableProvider(t *testing.T) {
tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir)
if provider.Name() != "hyprland" {
t.Errorf("Name() = %q, want hyprland", provider.Name())
}
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
if provider.GetOverridePath() != expectedPath {
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
}
}
func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir)
rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
rule.Actions.OpenFloating = boolPtr(true)
if err := provider.SetRule(rule); err != nil {
t.Fatalf("SetRule failed: %v", err)
}
rules, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules failed: %v", err)
}
if len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(rules))
}
if rules[0].ID != "test_id" {
t.Errorf("ID = %q, want test_id", rules[0].ID)
}
if rules[0].MatchCriteria.AppID != "^(firefox)$" {
t.Errorf("AppID = %q, want ^(firefox)$", rules[0].MatchCriteria.AppID)
}
}
func TestHyprlandRemoveRule(t *testing.T) {
tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir)
rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$")
rule1.Actions.OpenFloating = boolPtr(true)
rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$")
rule2.Actions.OpenFloating = boolPtr(true)
_ = provider.SetRule(rule1)
_ = provider.SetRule(rule2)
if err := provider.RemoveRule("rule1"); err != nil {
t.Fatalf("RemoveRule failed: %v", err)
}
rules, _ := provider.LoadDMSRules()
if len(rules) != 1 {
t.Fatalf("expected 1 rule after removal, got %d", len(rules))
}
if rules[0].ID != "rule2" {
t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID)
}
}
func TestHyprlandReorderRules(t *testing.T) {
tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir)
rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$")
rule1.Actions.OpenFloating = boolPtr(true)
rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$")
rule2.Actions.OpenFloating = boolPtr(true)
rule3 := newTestWindowRule("rule3", "Rule 3", "^(app3)$")
rule3.Actions.OpenFloating = boolPtr(true)
_ = provider.SetRule(rule1)
_ = provider.SetRule(rule2)
_ = provider.SetRule(rule3)
if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil {
t.Fatalf("ReorderRules failed: %v", err)
}
rules, _ := provider.LoadDMSRules()
if len(rules) != 3 {
t.Fatalf("expected 3 rules, got %d", len(rules))
}
expectedOrder := []string{"rule3", "rule1", "rule2"}
for i, expectedID := range expectedOrder {
if rules[i].ID != expectedID {
t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID)
}
}
}
func TestHyprlandParseConfigWithSource(t *testing.T) {
tmpDir := t.TempDir()
mainConfig := `
windowrulev2 = float, class:^(mainapp)$
source = ./extra.conf
`
extraConfig := `
windowrulev2 = tile, class:^(extraapp)$
`
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte(mainConfig), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "extra.conf"), []byte(extraConfig), 0644); err != nil {
t.Fatal(err)
}
parser := NewHyprlandRulesParser(tmpDir)
rules, err := parser.Parse()
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(rules) != 2 {
t.Errorf("expected 2 rules, got %d", len(rules))
}
}
func TestBoolToInt(t *testing.T) {
if boolToInt(true) != 1 {
t.Error("boolToInt(true) should be 1")
}
if boolToInt(false) != 0 {
t.Error("boolToInt(false) should be 0")
}
}

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