mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 21:42:51 -05:00
Compare commits
164 Commits
e1817027b1
...
chroma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ecb5af70 | ||
|
|
f2be6cfeb1 | ||
|
|
65486ed3cf | ||
|
|
cc30e2a9e4 | ||
|
|
ac68451cdf | ||
|
|
0f6ae11c3d | ||
|
|
7cb39f00ad | ||
|
|
f313d03348 | ||
|
|
1adbf3937b | ||
|
|
a685d9da52 | ||
|
|
13dededcc9 | ||
|
|
3bed2d9feb | ||
|
|
7241877995 | ||
|
|
340d79000c | ||
|
|
162ec909da | ||
|
|
53f5240d41 | ||
|
|
27f0df07af | ||
|
|
ad940b5884 | ||
|
|
ec8ab47462 | ||
|
|
35cbfeb008 | ||
|
|
7036362b9b | ||
|
|
2bcb33e85c | ||
|
|
76ac036f85 | ||
|
|
581073394a | ||
|
|
d7b7086b21 | ||
|
|
59be179821 | ||
|
|
1cf2f6b946 | ||
|
|
a57a9c2121 | ||
|
|
67568c3746 | ||
|
|
afce792b80 | ||
|
|
f5c7493dbb | ||
|
|
f9b9d98638 | ||
|
|
2a97e03fa6 | ||
|
|
d6dacc2975 | ||
|
|
aab4b6765d | ||
|
|
3539aca1f7 | ||
|
|
81fbe9eaba | ||
|
|
f9dc6de485 | ||
|
|
012022d370 | ||
|
|
993216e157 | ||
|
|
c992f2b582 | ||
|
|
3243adebca | ||
|
|
baccef57d4 | ||
|
|
a823095372 | ||
|
|
172a743de4 | ||
|
|
623eec3689 | ||
|
|
53a033fe35 | ||
|
|
c490ee24f4 | ||
|
|
cc1e49294e | ||
|
|
e6fa46ae26 | ||
|
|
35fe774a1b | ||
|
|
1e6a0f9423 | ||
|
|
cc1877aadb | ||
|
|
f1eb1fa9ba | ||
|
|
bdd01e335d | ||
|
|
4b7baf82cd | ||
|
|
15c88ce1d2 | ||
|
|
8891c388d0 | ||
|
|
cd9d92d884 | ||
|
|
1b69a5e62b | ||
|
|
61d311b157 | ||
|
|
6b76b86930 | ||
|
|
dcfb947c36 | ||
|
|
59893b7f44 | ||
|
|
d2c62f5533 | ||
|
|
2bbe9a0c45 | ||
|
|
4e2ce82c0a | ||
|
|
104762186f | ||
|
|
f1233ab1e3 | ||
|
|
d6b407ec37 | ||
|
|
022b4b4bb3 | ||
|
|
49b322582d | ||
|
|
1280bd047d | ||
|
|
6f206d7523 | ||
|
|
2e58283859 | ||
|
|
99a5721fe8 | ||
|
|
5302ebd840 | ||
|
|
fa427ea1ac | ||
|
|
7027bd1646 | ||
|
|
3c38e17472 | ||
|
|
510ea5d2e4 | ||
|
|
bb2234d328 | ||
|
|
edbdeb0fb8 | ||
|
|
19541fc573 | ||
|
|
7c936cacfb | ||
|
|
c60cd3a341 | ||
|
|
e37135f80d | ||
|
|
aac937cbcc | ||
|
|
4b46d022af | ||
|
|
7f0181b310 | ||
|
|
6a109274f8 | ||
|
|
0f09cc693a | ||
|
|
af0166a553 | ||
|
|
a283017f26 | ||
|
|
5ae2cd1dfb | ||
|
|
eece811fb0 | ||
|
|
1ff1f3a7f2 | ||
|
|
a21a846bf5 | ||
|
|
f5f21e738a | ||
|
|
033e62418a | ||
|
|
3c69e8b1cc | ||
|
|
118be27796 | ||
|
|
721d35d417 | ||
|
|
7bc3d5910d | ||
|
|
ccc7047be0 | ||
|
|
a5e107c89d | ||
|
|
646d60dcbf | ||
|
|
5dc7c0d797 | ||
|
|
db1de9df38 | ||
|
|
3dd21382ba | ||
|
|
ec2b3d0d4b | ||
|
|
a205df1bd6 | ||
|
|
e822fa73da | ||
|
|
634e75b80c | ||
|
|
ec5b507efc | ||
|
|
e6d289d48c | ||
|
|
745d7f26ce | ||
|
|
ad43053b94 | ||
|
|
721700190b | ||
|
|
8c9c936d0e | ||
|
|
842bf6e3ff | ||
|
|
c1fbeb3f5e | ||
|
|
c45eb2cccf | ||
|
|
1b5abca83a | ||
|
|
45818b202f | ||
|
|
1c8ce46f25 | ||
|
|
f762f9ae49 | ||
|
|
4484f6bd61 | ||
|
|
0076c45496 | ||
|
|
ab071e12aa | ||
|
|
8386b40c50 | ||
|
|
03a985228d | ||
|
|
ef7d7ec13d | ||
|
|
824792cca7 | ||
|
|
850e5b6572 | ||
|
|
64310854a6 | ||
|
|
4005a55bf2 | ||
|
|
0236fe3276 | ||
|
|
c1d95a3086 | ||
|
|
9b027df1d5 | ||
|
|
5e03afe7f0 | ||
|
|
145a974b6d | ||
|
|
d23fc9f2df | ||
|
|
7ac5191e8d | ||
|
|
29d27ebd6d | ||
|
|
e45075dd84 | ||
|
|
80bc87e76b | ||
|
|
76d88517ec | ||
|
|
151d695212 | ||
|
|
2e1bed5fb5 | ||
|
|
f163b97c17 | ||
|
|
436c99927e | ||
|
|
aa72eacae7 | ||
|
|
913bb2ff67 | ||
|
|
3bb2696263 | ||
|
|
166843ded4 | ||
|
|
02166a4ca5 | ||
|
|
f0f2e6ef72 | ||
|
|
8d8d5de5fd | ||
|
|
6d76f0b476 | ||
|
|
f3f720bb37 | ||
|
|
2bf85bc4dd | ||
|
|
faddc46185 | ||
|
|
2991aac82e |
9
.github/workflows/prek.yml
vendored
9
.github/workflows/prek.yml
vendored
@@ -11,5 +11,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install flatpak
|
||||
run: sudo apt update && sudo apt install -y flatpak
|
||||
|
||||
- name: Add flathub
|
||||
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Add a flatpak that mutagen could support
|
||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||
|
||||
- name: run pre-commit hooks
|
||||
uses: j178/prek-action@v1
|
||||
|
||||
78
.github/workflows/run-obs.yml
vendored
78
.github/workflows/run-obs.yml
vendored
@@ -4,13 +4,14 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
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: ""
|
||||
description: "Package to update"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dms
|
||||
- dms-git
|
||||
- all
|
||||
default: "dms"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
||||
required: false
|
||||
@@ -56,8 +57,9 @@ jobs:
|
||||
}
|
||||
|
||||
# Helper function to check dms stable tag
|
||||
# Sets LATEST_TAG variable in parent scope if update needed
|
||||
check_dms_stable() {
|
||||
local LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "v\?\([^"]*\)".*/\1/' || echo "")
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\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 "")
|
||||
|
||||
@@ -73,8 +75,8 @@ jobs:
|
||||
# Main logic
|
||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag push - always update stable package
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag selected or pushed - always update stable package
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -104,7 +106,12 @@ jobs:
|
||||
# Check each package and build list of those needing updates
|
||||
PACKAGES_TO_UPDATE=()
|
||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
||||
check_dms_stable && PACKAGES_TO_UPDATE+=("dms")
|
||||
if check_dms_stable; then
|
||||
PACKAGES_TO_UPDATE+=("dms")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
||||
@@ -129,6 +136,9 @@ 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
|
||||
@@ -161,12 +171,19 @@ jobs:
|
||||
- name: Determine packages to update
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag push event - use the pushed tag
|
||||
# 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
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION"
|
||||
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 }}"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
# Scheduled run - dms-git only
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
@@ -176,22 +193,28 @@ jobs:
|
||||
|
||||
# Determine version for dms stable
|
||||
if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then
|
||||
# For explicit dms selection, require tag_version
|
||||
if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
|
||||
VERSION="${{ github.event.inputs.tag_version }}"
|
||||
# Use github.ref if tag selected, otherwise auto-detect latest
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Using specified tag: $VERSION"
|
||||
echo "Using tag from GITHUB_REF: $VERSION"
|
||||
else
|
||||
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
|
||||
# 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
|
||||
fi
|
||||
elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
||||
# For "all", auto-detect if tag_version not specified
|
||||
if [[ -n "${{ github.event.inputs.tag_version }}" ]]; then
|
||||
VERSION="${{ github.event.inputs.tag_version }}"
|
||||
# Use github.ref if tag selected, otherwise auto-detect latest
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Using specified tag: $VERSION"
|
||||
echo "Using tag from GITHUB_REF: $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 "")
|
||||
@@ -206,7 +229,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 }}" ]] && [[ -z "${{ github.event.inputs.tag_version }}" ]]; then
|
||||
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})"
|
||||
else
|
||||
@@ -215,6 +238,9 @@ 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
|
||||
|
||||
20
.github/workflows/stable.yml
vendored
20
.github/workflows/stable.yml
vendored
@@ -5,15 +5,27 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-stable:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Push to stable branch
|
||||
run: git push origin HEAD:refs/heads/stable --force
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -108,3 +108,5 @@ bin/
|
||||
# direnv
|
||||
.envrc
|
||||
.direnv/
|
||||
quickshell/dms-plugins
|
||||
__pycache__
|
||||
|
||||
13
CHANGELOG.MD
13
CHANGELOG.MD
@@ -1,5 +1,12 @@
|
||||
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
|
||||
|
||||
# 1.2.0
|
||||
|
||||
- Added clipboard and clipboard history integration
|
||||
@@ -15,3 +22,9 @@ This file is more of a quick reference so I know what to account for before next
|
||||
- new IPC targets
|
||||
- Initial RTL support/i18n
|
||||
- Theme registry
|
||||
- Notification persistence & history
|
||||
- **BREAKING** vscode theme needs re-installed
|
||||
- dms doctor cmd
|
||||
- niri/hypr/mango gaps/window/border overrides
|
||||
- settings search
|
||||
- notification display ops on lock screen
|
||||
|
||||
1
Makefile
1
Makefile
@@ -43,7 +43,6 @@ 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:
|
||||
|
||||
@@ -68,3 +68,9 @@ packages:
|
||||
outpkg: mocks_wlclient
|
||||
interfaces:
|
||||
WaylandDisplay:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
|
||||
config:
|
||||
dir: "internal/mocks/utils"
|
||||
outpkg: mocks_utils
|
||||
interfaces:
|
||||
AppChecker:
|
||||
|
||||
193
core/cmd/dms/commands_chroma.go
Normal file
193
core/cmd/dms/commands_chroma.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
)
|
||||
|
||||
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().BoolVarP(&chromaMarkdown, "markdown", "m", false, "Render markdown with syntax-highlighted code blocks")
|
||||
|
||||
chromaCmd.AddCommand(chromaListLanguagesCmd)
|
||||
chromaCmd.AddCommand(chromaListStylesCmd)
|
||||
}
|
||||
|
||||
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]
|
||||
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 {
|
||||
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 = lexers.Get(chromaLanguage)
|
||||
if lexer == nil {
|
||||
fmt.Fprintf(os.Stderr, "Unknown language: %s\n", chromaLanguage)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else if filename != "" {
|
||||
lexer = lexers.Match(filename)
|
||||
}
|
||||
|
||||
// Try content analysis if no lexer found
|
||||
if lexer == nil {
|
||||
lexer = lexers.Analyse(source)
|
||||
}
|
||||
|
||||
// Fallback to plaintext
|
||||
if lexer == nil {
|
||||
lexer = lexers.Fallback
|
||||
}
|
||||
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
|
||||
// Get style
|
||||
style := styles.Get(chromaStyle)
|
||||
if style == nil {
|
||||
style = styles.Fallback
|
||||
}
|
||||
|
||||
// Create HTML formatter
|
||||
var formatter *html.Formatter
|
||||
if chromaInline {
|
||||
formatter = html.New(
|
||||
html.WithClasses(false),
|
||||
html.TabWidth(4),
|
||||
)
|
||||
} else {
|
||||
formatter = html.New(
|
||||
html.WithClasses(true),
|
||||
html.TabWidth(4),
|
||||
)
|
||||
}
|
||||
|
||||
// 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 err := formatter.Format(os.Stdout, style, iterator); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
@@ -144,6 +156,30 @@ var (
|
||||
clipConfigEnabled bool
|
||||
)
|
||||
|
||||
var clipExportCmd = &cobra.Command{
|
||||
Use: "export [file]",
|
||||
Short: "Export clipboard history to JSON",
|
||||
Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.",
|
||||
Run: runClipExport,
|
||||
}
|
||||
|
||||
var clipImportCmd = &cobra.Command{
|
||||
Use: "import <file>",
|
||||
Short: "Import clipboard history from JSON",
|
||||
Long: "Import clipboard history from JSON file exported by 'dms cl export'.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runClipImport,
|
||||
}
|
||||
|
||||
var clipMigrateCmd = &cobra.Command{
|
||||
Use: "cliphist-migrate [db-path]",
|
||||
Short: "Migrate from cliphist",
|
||||
Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.",
|
||||
Run: runClipMigrate,
|
||||
}
|
||||
|
||||
var clipMigrateDelete bool
|
||||
|
||||
func init() {
|
||||
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")
|
||||
@@ -170,8 +206,10 @@ func init() {
|
||||
|
||||
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
|
||||
|
||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||
|
||||
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
}
|
||||
|
||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
@@ -606,3 +644,154 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
|
||||
|
||||
fmt.Println("Config updated")
|
||||
}
|
||||
|
||||
func runClipExport(cmd *cobra.Command, args []string) {
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "clipboard.getHistory",
|
||||
}
|
||||
|
||||
resp, err := sendServerRequest(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get clipboard history: %v", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
log.Fatal("No clipboard history")
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(resp.Result, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(args[0], out, 0644); err != nil {
|
||||
log.Fatalf("Failed to write file: %v", err)
|
||||
}
|
||||
fmt.Printf("Exported to %s\n", args[0])
|
||||
}
|
||||
|
||||
func runClipImport(cmd *cobra.Command, args []string) {
|
||||
data, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
var entries []map[string]any
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
log.Fatalf("Failed to parse JSON: %v", err)
|
||||
}
|
||||
|
||||
var imported int
|
||||
for _, entry := range entries {
|
||||
dataStr, ok := entry["data"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mimeType, _ := entry["mimeType"].(string)
|
||||
if mimeType == "" {
|
||||
mimeType = "text/plain"
|
||||
}
|
||||
|
||||
var entryData []byte
|
||||
if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil {
|
||||
entryData = decoded
|
||||
} else {
|
||||
entryData = []byte(dataStr)
|
||||
}
|
||||
|
||||
if err := clipboard.Store(entryData, mimeType); err != nil {
|
||||
log.Errorf("Failed to store entry: %v", err)
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
fmt.Printf("Imported %d entries\n", imported)
|
||||
}
|
||||
|
||||
func runClipMigrate(cmd *cobra.Command, args []string) {
|
||||
dbPath := getCliphistPath()
|
||||
if len(args) > 0 {
|
||||
dbPath = args[0]
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
log.Fatalf("Cliphist db not found: %s", dbPath)
|
||||
}
|
||||
|
||||
db, err := bolt.Open(dbPath, 0644, &bolt.Options{
|
||||
ReadOnly: true,
|
||||
Timeout: 1 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open cliphist db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var migrated int
|
||||
err = db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte("b"))
|
||||
if b == nil {
|
||||
return fmt.Errorf("cliphist bucket not found")
|
||||
}
|
||||
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
mimeType := detectMimeType(v)
|
||||
if err := clipboard.Store(v, mimeType); err != nil {
|
||||
log.Errorf("Failed to store entry %d: %v", btoi(k), err)
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Migrated %d entries from cliphist\n", migrated)
|
||||
|
||||
if !clipMigrateDelete {
|
||||
return
|
||||
}
|
||||
|
||||
db.Close()
|
||||
if err := os.Remove(dbPath); err != nil {
|
||||
log.Errorf("Failed to delete cliphist db: %v", err)
|
||||
return
|
||||
}
|
||||
os.Remove(filepath.Dir(dbPath))
|
||||
fmt.Println("Deleted cliphist db")
|
||||
}
|
||||
|
||||
func getCliphistPath() string {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db")
|
||||
}
|
||||
return filepath.Join(cacheDir, "cliphist", "db")
|
||||
}
|
||||
|
||||
func detectMimeType(data []byte) string {
|
||||
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
|
||||
return "image/png"
|
||||
}
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
func btoi(v []byte) uint64 {
|
||||
return binary.BigEndian.Uint64(v)
|
||||
}
|
||||
|
||||
@@ -511,7 +511,12 @@ func getCommonCommands() []*cobra.Command {
|
||||
colorCmd,
|
||||
screenshotCmd,
|
||||
notifyActionCmd,
|
||||
notifyCmd,
|
||||
genericNotifyActionCmd,
|
||||
matugenCmd,
|
||||
clipboardCmd,
|
||||
doctorCmd,
|
||||
configCmd,
|
||||
chromaCmd,
|
||||
}
|
||||
}
|
||||
|
||||
318
core/cmd/dms/commands_config.go
Normal file
318
core/cmd/dms/commands_config.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Configuration utilities",
|
||||
}
|
||||
|
||||
var resolveIncludeCmd = &cobra.Command{
|
||||
Use: "resolve-include <compositor> <filename>",
|
||||
Short: "Check if a file is included in compositor config",
|
||||
Long: "Recursively check if a file is included/sourced in compositor configuration. Returns JSON with exists and included status.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
|
||||
case 1:
|
||||
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: runResolveInclude,
|
||||
}
|
||||
|
||||
func init() {
|
||||
configCmd.AddCommand(resolveIncludeCmd)
|
||||
}
|
||||
|
||||
type IncludeResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
Included bool `json:"included"`
|
||||
}
|
||||
|
||||
func runResolveInclude(cmd *cobra.Command, args []string) {
|
||||
compositor := strings.ToLower(args[0])
|
||||
filename := args[1]
|
||||
|
||||
var result IncludeResult
|
||||
var err error
|
||||
|
||||
switch compositor {
|
||||
case "hyprland":
|
||||
result, err = checkHyprlandInclude(filename)
|
||||
case "niri":
|
||||
result, err = checkNiriInclude(filename)
|
||||
case "mangowc", "dwl", "mango":
|
||||
result, err = checkMangoWCInclude(filename)
|
||||
default:
|
||||
log.Fatalf("Unknown compositor: %s", compositor)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error checking include: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.Marshal(result)
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||
if err != nil {
|
||||
return IncludeResult{}, err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
result.Exists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(configDir, "hyprland.conf")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if processed[absPath] {
|
||||
return false
|
||||
}
|
||||
processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "source") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
if matchesTarget(sourcePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if hyprlandFindInclude(expanded, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func checkNiriInclude(filename string) (IncludeResult, error) {
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||
if err != nil {
|
||||
return IncludeResult{}, err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
result.Exists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(configDir, "config.kdl")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func niriFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if processed[absPath] {
|
||||
return false
|
||||
}
|
||||
processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
content := string(data)
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "include") {
|
||||
continue
|
||||
}
|
||||
|
||||
startQuote := strings.Index(trimmed, "\"")
|
||||
if startQuote == -1 {
|
||||
continue
|
||||
}
|
||||
endQuote := strings.LastIndex(trimmed, "\"")
|
||||
if endQuote <= startQuote {
|
||||
continue
|
||||
}
|
||||
|
||||
includePath := trimmed[startQuote+1 : endQuote]
|
||||
if matchesTarget(includePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := includePath
|
||||
if !filepath.IsAbs(includePath) {
|
||||
fullPath = filepath.Join(baseDir, includePath)
|
||||
}
|
||||
|
||||
if niriFindInclude(fullPath, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func checkMangoWCInclude(filename string) (IncludeResult, error) {
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/mango")
|
||||
if err != nil {
|
||||
return IncludeResult{}, err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
result.Exists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(configDir, "config.conf")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
mainConfig = filepath.Join(configDir, "mango.conf")
|
||||
}
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mangowcFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if processed[absPath] {
|
||||
return false
|
||||
}
|
||||
processed[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(absPath)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "source") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
if matchesTarget(sourcePath, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if mangowcFindInclude(expanded, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesTarget(path, target string) bool {
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
target = strings.TrimPrefix(target, "./")
|
||||
return path == target || strings.HasSuffix(path, "/"+target)
|
||||
}
|
||||
931
core/cmd/dms/commands_doctor.go
Normal file
931
core/cmd/dms/commands_doctor.go
Normal file
@@ -0,0 +1,931 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type status string
|
||||
|
||||
const (
|
||||
statusOK status = "ok"
|
||||
statusWarn status = "warn"
|
||||
statusError status = "error"
|
||||
statusInfo status = "info"
|
||||
)
|
||||
|
||||
func (s status) IconStyle(styles tui.Styles) (string, lipgloss.Style) {
|
||||
switch s {
|
||||
case statusOK:
|
||||
return "●", styles.Success
|
||||
case statusWarn:
|
||||
return "●", styles.Warning
|
||||
case statusError:
|
||||
return "●", styles.Error
|
||||
default:
|
||||
return "○", styles.Subtle
|
||||
}
|
||||
}
|
||||
|
||||
type DoctorStatus struct {
|
||||
Errors []checkResult
|
||||
Warnings []checkResult
|
||||
OK []checkResult
|
||||
Info []checkResult
|
||||
}
|
||||
|
||||
func (ds *DoctorStatus) Add(r checkResult) {
|
||||
switch r.status {
|
||||
case statusError:
|
||||
ds.Errors = append(ds.Errors, r)
|
||||
case statusWarn:
|
||||
ds.Warnings = append(ds.Warnings, r)
|
||||
case statusOK:
|
||||
ds.OK = append(ds.OK, r)
|
||||
case statusInfo:
|
||||
ds.Info = append(ds.Info, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DoctorStatus) HasIssues() bool {
|
||||
return len(ds.Errors) > 0 || len(ds.Warnings) > 0
|
||||
}
|
||||
|
||||
func (ds *DoctorStatus) ErrorCount() int {
|
||||
return len(ds.Errors)
|
||||
}
|
||||
|
||||
func (ds *DoctorStatus) WarningCount() int {
|
||||
return len(ds.Warnings)
|
||||
}
|
||||
|
||||
func (ds *DoctorStatus) OKCount() int {
|
||||
return len(ds.OK)
|
||||
}
|
||||
|
||||
var (
|
||||
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
||||
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
||||
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
||||
riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`)
|
||||
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Diagnose DMS installation and dependencies",
|
||||
Long: "Check system health, verify dependencies, and diagnose configuration issues for DMS",
|
||||
Run: runDoctor,
|
||||
}
|
||||
|
||||
var (
|
||||
doctorVerbose bool
|
||||
doctorJSON bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
|
||||
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
|
||||
}
|
||||
|
||||
type category int
|
||||
|
||||
const (
|
||||
catSystem category = iota
|
||||
catVersions
|
||||
catInstallation
|
||||
catCompositor
|
||||
catQuickshellFeatures
|
||||
catOptionalFeatures
|
||||
catConfigFiles
|
||||
catServices
|
||||
catEnvironment
|
||||
)
|
||||
|
||||
func (c category) String() string {
|
||||
switch c {
|
||||
case catSystem:
|
||||
return "System"
|
||||
case catVersions:
|
||||
return "Versions"
|
||||
case catInstallation:
|
||||
return "Installation"
|
||||
case catCompositor:
|
||||
return "Compositor"
|
||||
case catQuickshellFeatures:
|
||||
return "Quickshell Features"
|
||||
case catOptionalFeatures:
|
||||
return "Optional Features"
|
||||
case catConfigFiles:
|
||||
return "Config Files"
|
||||
case catServices:
|
||||
return "Services"
|
||||
case catEnvironment:
|
||||
return "Environment"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
checkNameMaxLength = 21
|
||||
doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
|
||||
)
|
||||
|
||||
type checkResult struct {
|
||||
category category
|
||||
name string
|
||||
status status
|
||||
message string
|
||||
details string
|
||||
url string
|
||||
}
|
||||
|
||||
type checkResultJSON struct {
|
||||
Category string `json:"category"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type doctorOutputJSON struct {
|
||||
Summary struct {
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
OK int `json:"ok"`
|
||||
Info int `json:"info"`
|
||||
} `json:"summary"`
|
||||
Results []checkResultJSON `json:"results"`
|
||||
}
|
||||
|
||||
func (r checkResult) toJSON() checkResultJSON {
|
||||
return checkResultJSON{
|
||||
Category: r.category.String(),
|
||||
Name: r.name,
|
||||
Status: string(r.status),
|
||||
Message: r.message,
|
||||
Details: r.details,
|
||||
URL: r.url,
|
||||
}
|
||||
}
|
||||
|
||||
func runDoctor(cmd *cobra.Command, args []string) {
|
||||
if !doctorJSON {
|
||||
printDoctorHeader()
|
||||
}
|
||||
|
||||
qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
|
||||
|
||||
results := slices.Concat(
|
||||
checkSystemInfo(),
|
||||
checkVersions(qsMissingFeatures),
|
||||
checkDMSInstallation(),
|
||||
checkWindowManagers(),
|
||||
qsFeatures,
|
||||
checkOptionalDependencies(),
|
||||
checkConfigurationFiles(),
|
||||
checkSystemdServices(),
|
||||
checkEnvironmentVars(),
|
||||
)
|
||||
|
||||
if doctorJSON {
|
||||
printResultsJSON(results)
|
||||
} else {
|
||||
printResults(results)
|
||||
printSummary(results, qsMissingFeatures)
|
||||
}
|
||||
}
|
||||
|
||||
func printDoctorHeader() {
|
||||
theme := tui.TerminalTheme()
|
||||
styles := tui.NewStyles(theme)
|
||||
|
||||
fmt.Println(getThemedASCII())
|
||||
fmt.Println(styles.Title.Render("System Health Check"))
|
||||
fmt.Println(styles.Subtle.Render("──────────────────────────────────────"))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func checkSystemInfo() []checkResult {
|
||||
var results []checkResult
|
||||
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
status, message, details := statusWarn, fmt.Sprintf("Unknown (%v)", err), ""
|
||||
|
||||
if strings.Contains(err.Error(), "Unsupported distribution") {
|
||||
osRelease := readOSRelease()
|
||||
switch {
|
||||
case osRelease["ID"] == "nixos":
|
||||
status = statusOK
|
||||
message = osRelease["PRETTY_NAME"]
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"])
|
||||
}
|
||||
details = "Supported for runtime (install via NixOS module or Flake)"
|
||||
case osRelease["PRETTY_NAME"] != "":
|
||||
message = fmt.Sprintf("%s (not supported by dms setup)", osRelease["PRETTY_NAME"])
|
||||
details = "DMS may work but automatic installation is not available"
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, checkResult{catSystem, "Operating System", status, message, details, doctorDocsURL + "#operating-system"})
|
||||
} else {
|
||||
status := statusOK
|
||||
message := osInfo.PrettyName
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID)
|
||||
}
|
||||
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
||||
status = statusWarn
|
||||
message += " (version may not be fully supported)"
|
||||
}
|
||||
results = append(results, checkResult{
|
||||
catSystem, "Operating System", status, message,
|
||||
fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture),
|
||||
doctorDocsURL + "#operating-system",
|
||||
})
|
||||
}
|
||||
|
||||
arch := runtime.GOARCH
|
||||
archStatus := statusOK
|
||||
if arch != "amd64" && arch != "arm64" {
|
||||
archStatus = statusError
|
||||
}
|
||||
results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, "", doctorDocsURL + "#architecture"})
|
||||
|
||||
waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
|
||||
xdgSessionType := os.Getenv("XDG_SESSION_TYPE")
|
||||
|
||||
switch {
|
||||
case waylandDisplay != "" || xdgSessionType == "wayland":
|
||||
results = append(results, checkResult{
|
||||
catSystem, "Display Server", statusOK, "Wayland",
|
||||
fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay),
|
||||
doctorDocsURL + "#display-server",
|
||||
})
|
||||
case xdgSessionType == "x11":
|
||||
results = append(results, checkResult{catSystem, "Display Server", statusError, "X11 (DMS requires Wayland)", "", doctorDocsURL + "#display-server"})
|
||||
default:
|
||||
results = append(results, checkResult{
|
||||
catSystem, "Display Server", statusWarn, "Unknown (ensure you're running Wayland)",
|
||||
fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType),
|
||||
doctorDocsURL + "#display-server",
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func checkEnvironmentVars() []checkResult {
|
||||
var results []checkResult
|
||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
||||
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
||||
return results
|
||||
}
|
||||
|
||||
func checkEnvVar(name string) []checkResult {
|
||||
value := os.Getenv(name)
|
||||
if value != "" {
|
||||
return []checkResult{{catEnvironment, name, statusInfo, value, "", doctorDocsURL + "#environment-variables"}}
|
||||
}
|
||||
if doctorVerbose {
|
||||
return []checkResult{{catEnvironment, name, statusInfo, "Not set", "", doctorDocsURL + "#environment-variables"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readOSRelease() map[string]string {
|
||||
result := make(map[string]string)
|
||||
data, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
|
||||
result[parts[0]] = strings.Trim(parts[1], "\"")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func checkVersions(qsMissingFeatures bool) []checkResult {
|
||||
dmsCliPath, _ := os.Executable()
|
||||
dmsCliDetails := ""
|
||||
if doctorVerbose {
|
||||
dmsCliDetails = dmsCliPath
|
||||
}
|
||||
|
||||
results := []checkResult{
|
||||
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"},
|
||||
}
|
||||
|
||||
qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures)
|
||||
qsDetails := ""
|
||||
if doctorVerbose && qsPath != "" {
|
||||
qsDetails = qsPath
|
||||
}
|
||||
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails, doctorDocsURL + "#quickshell"})
|
||||
|
||||
dmsVersion, dmsPath := getDMSShellVersion()
|
||||
if dmsVersion != "" {
|
||||
results = append(results, checkResult{catVersions, "DMS Shell", statusOK, dmsVersion, dmsPath, doctorDocsURL + "#dms-shell"})
|
||||
} else {
|
||||
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func getDMSShellVersion() (version, path string) {
|
||||
if err := findConfig(nil, nil); err == nil && configPath != "" {
|
||||
versionFile := filepath.Join(configPath, "VERSION")
|
||||
if data, err := os.ReadFile(versionFile); err == nil {
|
||||
return strings.TrimSpace(string(data)), configPath
|
||||
}
|
||||
return "installed", configPath
|
||||
}
|
||||
|
||||
if dmsPath, err := config.LocateDMSConfig(); err == nil {
|
||||
versionFile := filepath.Join(dmsPath, "VERSION")
|
||||
if data, err := os.ReadFile(versionFile); err == nil {
|
||||
return strings.TrimSpace(string(data)), dmsPath
|
||||
}
|
||||
return "installed", dmsPath
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) {
|
||||
if !utils.CommandExists("qs") {
|
||||
return "Not installed", statusError, ""
|
||||
}
|
||||
|
||||
qsPath, _ := exec.LookPath("qs")
|
||||
|
||||
output, err := exec.Command("qs", "--version").Output()
|
||||
if err != nil {
|
||||
return "Installed (version check failed)", statusWarn, qsPath
|
||||
}
|
||||
|
||||
fullVersion := strings.TrimSpace(string(output))
|
||||
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 {
|
||||
if version.CompareVersions(matches[1], "0.2.0") < 0 {
|
||||
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath
|
||||
}
|
||||
if missingFeatures {
|
||||
return fullVersion, statusWarn, qsPath
|
||||
}
|
||||
return fullVersion, statusOK, qsPath
|
||||
}
|
||||
|
||||
return fullVersion, statusWarn, qsPath
|
||||
}
|
||||
|
||||
func checkDMSInstallation() []checkResult {
|
||||
var results []checkResult
|
||||
|
||||
dmsPath := ""
|
||||
if err := findConfig(nil, nil); err == nil && configPath != "" {
|
||||
dmsPath = configPath
|
||||
} else if path, err := config.LocateDMSConfig(); err == nil {
|
||||
dmsPath = path
|
||||
}
|
||||
|
||||
if dmsPath == "" {
|
||||
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
|
||||
}
|
||||
|
||||
results = append(results, checkResult{catInstallation, "DMS Configuration", statusOK, "Found", dmsPath, doctorDocsURL + "#dms-configuration"})
|
||||
|
||||
shellQml := filepath.Join(dmsPath, "shell.qml")
|
||||
if _, err := os.Stat(shellQml); err != nil {
|
||||
results = append(results, checkResult{catInstallation, "shell.qml", statusError, "Missing", shellQml, doctorDocsURL + "#dms-configuration"})
|
||||
} else {
|
||||
results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml, doctorDocsURL + "#dms-configuration"})
|
||||
}
|
||||
|
||||
if doctorVerbose {
|
||||
installType := "Unknown"
|
||||
switch {
|
||||
case strings.Contains(dmsPath, "/nix/store"):
|
||||
installType = "Nix store"
|
||||
case strings.Contains(dmsPath, ".local/share") || strings.Contains(dmsPath, "/usr/share"):
|
||||
installType = "System package"
|
||||
case strings.Contains(dmsPath, ".config"):
|
||||
installType = "User config"
|
||||
}
|
||||
results = append(results, checkResult{catInstallation, "Install Type", statusInfo, installType, dmsPath, doctorDocsURL + "#dms-configuration"})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func checkWindowManagers() []checkResult {
|
||||
compositors := []struct {
|
||||
name, versionCmd, versionArg string
|
||||
versionRegex *regexp.Regexp
|
||||
commands []string
|
||||
}{
|
||||
{"Hyprland", "Hyprland", "--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
|
||||
foundAny := false
|
||||
|
||||
for _, c := range compositors {
|
||||
if !slices.ContainsFunc(c.commands, utils.CommandExists) {
|
||||
continue
|
||||
}
|
||||
foundAny = true
|
||||
var compositorPath string
|
||||
for _, cmd := range c.commands {
|
||||
if path, err := exec.LookPath(cmd); err == nil {
|
||||
compositorPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
details := ""
|
||||
if doctorVerbose && compositorPath != "" {
|
||||
details = compositorPath
|
||||
}
|
||||
results = append(results, checkResult{
|
||||
catCompositor, c.name, statusOK,
|
||||
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
}
|
||||
|
||||
if !foundAny {
|
||||
results = append(results, checkResult{
|
||||
catCompositor, "Compositor", statusError,
|
||||
"No supported Wayland compositor found",
|
||||
"Install Hyprland, niri, Sway, River, or Wayfire",
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
}
|
||||
|
||||
if wm := detectRunningWM(); wm != "" {
|
||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
||||
output, err := exec.Command(cmd, arg).CombinedOutput()
|
||||
if err != nil && len(output) == 0 {
|
||||
return "installed"
|
||||
}
|
||||
|
||||
outStr := string(output)
|
||||
if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 {
|
||||
ver := matches[1]
|
||||
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
|
||||
return ver + " (git)"
|
||||
}
|
||||
return ver
|
||||
}
|
||||
return strings.TrimSpace(outStr)
|
||||
}
|
||||
|
||||
func detectRunningWM() string {
|
||||
switch {
|
||||
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
|
||||
return "Hyprland"
|
||||
case os.Getenv("NIRI_SOCKET") != "":
|
||||
return "niri"
|
||||
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
||||
return os.Getenv("XDG_CURRENT_DESKTOP")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkQuickshellFeatures() ([]checkResult, bool) {
|
||||
if !utils.CommandExists("qs") {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
tmpDir := os.TempDir()
|
||||
testScript := filepath.Join(tmpDir, "qs-feature-test.qml")
|
||||
defer os.Remove(testScript)
|
||||
|
||||
qmlContent := `
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property bool polkitAvailable: false
|
||||
property bool idleMonitorAvailable: false
|
||||
property bool idleInhibitorAvailable: false
|
||||
property bool shortcutInhibitorAvailable: false
|
||||
|
||||
Timer {
|
||||
interval: 50
|
||||
running: true
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
try {
|
||||
var polkitTest = Qt.createQmlObject(
|
||||
'import Quickshell.Services.Polkit; import QtQuick; Item {}',
|
||||
root
|
||||
)
|
||||
root.polkitAvailable = true
|
||||
polkitTest.destroy()
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
var testItem = Qt.createQmlObject(
|
||||
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
|
||||
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
|
||||
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
|
||||
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
|
||||
'}',
|
||||
root
|
||||
)
|
||||
root.idleMonitorAvailable = testItem.hasIdleMonitor
|
||||
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
|
||||
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
|
||||
testItem.destroy()
|
||||
} catch (e) {}
|
||||
|
||||
console.warn(root.polkitAvailable ? "FEATURE:Polkit:OK" : "FEATURE:Polkit:UNAVAILABLE")
|
||||
console.warn(root.idleMonitorAvailable ? "FEATURE:IdleMonitor:OK" : "FEATURE:IdleMonitor:UNAVAILABLE")
|
||||
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
|
||||
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
|
||||
|
||||
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
if err := os.WriteFile(testScript, []byte(qmlContent), 0644); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
cmd := exec.Command("qs", "-p", testScript)
|
||||
cmd.Env = append(os.Environ(), "NO_COLOR=1")
|
||||
output, _ := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
features := []struct{ name, desc string }{
|
||||
{"Polkit", "Authentication prompts"},
|
||||
{"IdleMonitor", "Idle detection"},
|
||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
missingFeatures := false
|
||||
|
||||
for _, f := range features {
|
||||
available := strings.Contains(outputStr, fmt.Sprintf("FEATURE:%s:OK", f.name))
|
||||
status, message := statusOK, "Available"
|
||||
if !available {
|
||||
status, message = statusInfo, "Not available"
|
||||
missingFeatures = true
|
||||
}
|
||||
results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc, doctorDocsURL + "#quickshell-features"})
|
||||
}
|
||||
|
||||
return results, missingFeatures
|
||||
}
|
||||
|
||||
func checkI2CAvailability() checkResult {
|
||||
ddc, err := brightness.NewDDCBackend()
|
||||
if err != nil {
|
||||
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
||||
}
|
||||
defer ddc.Close()
|
||||
|
||||
devices, err := ddc.GetDevices()
|
||||
if err != nil || len(devices) == 0 {
|
||||
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "No monitors detected", "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
||||
}
|
||||
|
||||
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
|
||||
}
|
||||
|
||||
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
||||
switch stackResult.Backend {
|
||||
case network.BackendNetworkManager:
|
||||
return "NetworkManager"
|
||||
case network.BackendIwd:
|
||||
return "iwd"
|
||||
case network.BackendNetworkd:
|
||||
if stackResult.HasIwd {
|
||||
return "iwd + systemd-networkd"
|
||||
}
|
||||
return "systemd-networkd"
|
||||
case network.BackendConnMan:
|
||||
return "ConnMan"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getOptionalDBusStatus(busName string) (status, string) {
|
||||
if utils.IsDBusServiceAvailable(busName) {
|
||||
return statusOK, "Available"
|
||||
} else {
|
||||
return statusWarn, "Not available"
|
||||
}
|
||||
}
|
||||
|
||||
func checkOptionalDependencies() []checkResult {
|
||||
var results []checkResult
|
||||
|
||||
optionalFeaturesURL := doctorDocsURL + "#optional-features"
|
||||
|
||||
accountsStatus, accountsMsg := getOptionalDBusStatus("org.freedesktop.Accounts")
|
||||
results = append(results, checkResult{catOptionalFeatures, "accountsservice", accountsStatus, accountsMsg, "User accounts", optionalFeaturesURL})
|
||||
|
||||
ppdStatus, ppdMsg := getOptionalDBusStatus("org.freedesktop.UPower.PowerProfiles")
|
||||
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", ppdStatus, ppdMsg, "Power profile management", optionalFeaturesURL})
|
||||
|
||||
logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1")
|
||||
results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL})
|
||||
|
||||
results = append(results, checkI2CAvailability())
|
||||
|
||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
|
||||
} else {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
|
||||
}
|
||||
|
||||
networkResult, err := network.DetectNetworkStack()
|
||||
networkStatus, networkMessage, networkDetails := statusOK, "Not available", "Network management"
|
||||
|
||||
if err == nil && networkResult.Backend != network.BackendNone {
|
||||
networkMessage = detectNetworkBackend(networkResult)
|
||||
if doctorVerbose {
|
||||
networkDetails = networkResult.ChosenReason
|
||||
}
|
||||
} else {
|
||||
networkStatus = statusInfo
|
||||
}
|
||||
|
||||
results = append(results, checkResult{catOptionalFeatures, "Network", networkStatus, networkMessage, networkDetails, optionalFeaturesURL})
|
||||
|
||||
deps := []struct {
|
||||
name, cmd, desc string
|
||||
important bool
|
||||
}{
|
||||
{"matugen", "matugen", "Dynamic theming", true},
|
||||
{"dgop", "dgop", "System monitoring", true},
|
||||
{"cava", "cava", "Audio visualizer", true},
|
||||
{"khal", "khal", "Calendar events", false},
|
||||
{"danksearch", "dsearch", "File search", false},
|
||||
{"fprintd", "fprintd-list", "Fingerprint auth", false},
|
||||
}
|
||||
|
||||
for _, d := range deps {
|
||||
found := utils.CommandExists(d.cmd)
|
||||
|
||||
switch {
|
||||
case found:
|
||||
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, "Installed", d.desc, optionalFeaturesURL})
|
||||
case d.important:
|
||||
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, optionalFeaturesURL})
|
||||
default:
|
||||
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, optionalFeaturesURL})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func checkConfigurationFiles() []checkResult {
|
||||
configDir, _ := os.UserConfigDir()
|
||||
cacheDir, _ := os.UserCacheDir()
|
||||
dmsDir := "DankMaterialShell"
|
||||
|
||||
configFiles := []struct{ name, path string }{
|
||||
{"settings.json", filepath.Join(configDir, dmsDir, "settings.json")},
|
||||
{"clsettings.json", filepath.Join(configDir, dmsDir, "clsettings.json")},
|
||||
{"plugin_settings.json", filepath.Join(configDir, dmsDir, "plugin_settings.json")},
|
||||
{"session.json", filepath.Join(utils.XDGStateHome(), dmsDir, "session.json")},
|
||||
{"dms-colors.json", filepath.Join(cacheDir, dmsDir, "dms-colors.json")},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
for _, cf := range configFiles {
|
||||
info, err := os.Stat(cf.path)
|
||||
if err != nil {
|
||||
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path, doctorDocsURL + "#config-files"})
|
||||
continue
|
||||
}
|
||||
|
||||
status := statusOK
|
||||
message := "Present"
|
||||
if info.Mode().Perm()&0200 == 0 {
|
||||
status = statusWarn
|
||||
message += " (read-only)"
|
||||
}
|
||||
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path, doctorDocsURL + "#config-files"})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func checkSystemdServices() []checkResult {
|
||||
if !utils.CommandExists("systemctl") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
|
||||
dmsState := getServiceState("dms", true)
|
||||
if !dmsState.exists {
|
||||
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"})
|
||||
} else {
|
||||
status, message := statusOK, dmsState.enabled
|
||||
if dmsState.active != "" {
|
||||
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
|
||||
}
|
||||
switch {
|
||||
case dmsState.enabled == "disabled":
|
||||
status, message = statusWarn, "Disabled"
|
||||
case dmsState.active == "failed" || dmsState.active == "inactive":
|
||||
status = statusError
|
||||
}
|
||||
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
|
||||
}
|
||||
|
||||
greetdState := getServiceState("greetd", false)
|
||||
switch {
|
||||
case greetdState.exists:
|
||||
status := statusOK
|
||||
if greetdState.enabled == "disabled" {
|
||||
status = statusInfo
|
||||
}
|
||||
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"})
|
||||
case doctorVerbose:
|
||||
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
type serviceState struct {
|
||||
exists bool
|
||||
enabled string
|
||||
active string
|
||||
}
|
||||
|
||||
func getServiceState(name string, userService bool) serviceState {
|
||||
args := []string{"is-enabled", name}
|
||||
if userService {
|
||||
args = []string{"--user", "is-enabled", name}
|
||||
}
|
||||
|
||||
output, _ := exec.Command("systemctl", args...).Output()
|
||||
enabled := strings.TrimSpace(string(output))
|
||||
|
||||
if enabled == "" || enabled == "not-found" {
|
||||
return serviceState{}
|
||||
}
|
||||
|
||||
state := serviceState{exists: true, enabled: enabled}
|
||||
|
||||
if userService {
|
||||
output, _ = exec.Command("systemctl", "--user", "is-active", name).Output()
|
||||
if active := strings.TrimSpace(string(output)); active != "" && active != "unknown" {
|
||||
state.active = active
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
func printResults(results []checkResult) {
|
||||
theme := tui.TerminalTheme()
|
||||
styles := tui.NewStyles(theme)
|
||||
|
||||
currentCategory := category(-1)
|
||||
for _, r := range results {
|
||||
if r.category != currentCategory {
|
||||
if currentCategory != -1 {
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Printf(" %s\n", styles.Bold.Render(r.category.String()))
|
||||
currentCategory = r.category
|
||||
}
|
||||
printResultLine(r, styles)
|
||||
}
|
||||
}
|
||||
|
||||
func printResultsJSON(results []checkResult) {
|
||||
var ds DoctorStatus
|
||||
for _, r := range results {
|
||||
ds.Add(r)
|
||||
}
|
||||
|
||||
output := doctorOutputJSON{}
|
||||
output.Summary.Errors = ds.ErrorCount()
|
||||
output.Summary.Warnings = ds.WarningCount()
|
||||
output.Summary.OK = ds.OKCount()
|
||||
output.Summary.Info = len(ds.Info)
|
||||
|
||||
output.Results = make([]checkResultJSON, 0, len(results))
|
||||
for _, r := range results {
|
||||
output.Results = append(output.Results, r.toJSON())
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(output); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printResultLine(r checkResult, styles tui.Styles) {
|
||||
icon, style := r.status.IconStyle(styles)
|
||||
|
||||
name := r.name
|
||||
nameLen := len(name)
|
||||
|
||||
if nameLen > checkNameMaxLength {
|
||||
name = name[:checkNameMaxLength-1] + "…"
|
||||
nameLen = checkNameMaxLength
|
||||
}
|
||||
dots := strings.Repeat("·", checkNameMaxLength-nameLen)
|
||||
|
||||
fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message)
|
||||
|
||||
if doctorVerbose && r.details != "" {
|
||||
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
|
||||
}
|
||||
|
||||
if (r.status == statusError || r.status == statusWarn) && r.url != "" {
|
||||
fmt.Printf(" %s\n", styles.Subtle.Render("→ "+r.url))
|
||||
}
|
||||
}
|
||||
|
||||
func printSummary(results []checkResult, qsMissingFeatures bool) {
|
||||
theme := tui.TerminalTheme()
|
||||
styles := tui.NewStyles(theme)
|
||||
|
||||
var ds DoctorStatus
|
||||
for _, r := range results {
|
||||
ds.Add(r)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", styles.Subtle.Render("──────────────────────────────────────"))
|
||||
|
||||
if !ds.HasIssues() {
|
||||
fmt.Printf(" %s\n", styles.Success.Render("✓ All checks passed!"))
|
||||
} else {
|
||||
var parts []string
|
||||
|
||||
if ds.ErrorCount() > 0 {
|
||||
parts = append(parts, styles.Error.Render(fmt.Sprintf("%d error(s)", ds.ErrorCount())))
|
||||
}
|
||||
if ds.WarningCount() > 0 {
|
||||
parts = append(parts, styles.Warning.Render(fmt.Sprintf("%d warning(s)", ds.WarningCount())))
|
||||
}
|
||||
parts = append(parts, styles.Success.Render(fmt.Sprintf("%d ok", ds.OKCount())))
|
||||
fmt.Printf(" %s\n", strings.Join(parts, ", "))
|
||||
|
||||
if qsMissingFeatures {
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", styles.Subtle.Render("→ Consider using quickshell-git for full feature support"))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -57,12 +57,14 @@ var keybindsRemoveCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
|
||||
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
|
||||
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
|
||||
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
|
||||
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
|
||||
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
|
||||
|
||||
keybindsCmd.AddCommand(keybindsListCmd)
|
||||
keybindsCmd.AddCommand(keybindsShowCmd)
|
||||
@@ -110,12 +112,21 @@ func initializeProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
func runKeybindsList(_ *cobra.Command, _ []string) {
|
||||
func runKeybindsList(cmd *cobra.Command, _ []string) {
|
||||
providerList := keybinds.GetDefaultRegistry().List()
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if asJSON {
|
||||
output, _ := json.Marshal(providerList)
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
return
|
||||
}
|
||||
|
||||
if len(providerList) == 0 {
|
||||
fmt.Fprintln(os.Stdout, "No providers available")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "Available providers:")
|
||||
for _, name := range providerList {
|
||||
fmt.Fprintf(os.Stdout, " - %s\n", name)
|
||||
@@ -201,6 +212,9 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
|
||||
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
|
||||
options["repeat"] = false
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("flags"); v != "" {
|
||||
options["flags"] = v
|
||||
}
|
||||
|
||||
desc, _ := cmd.Flags().GetString("desc")
|
||||
if err := writable.SetBind(key, action, desc, options); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -29,9 +30,16 @@ var matugenQueueCmd = &cobra.Command{
|
||||
Run: runMatugenQueue,
|
||||
}
|
||||
|
||||
var matugenCheckCmd = &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check which template apps are detected",
|
||||
Run: runMatugenCheck,
|
||||
}
|
||||
|
||||
func init() {
|
||||
matugenCmd.AddCommand(matugenGenerateCmd)
|
||||
matugenCmd.AddCommand(matugenQueueCmd)
|
||||
matugenCmd.AddCommand(matugenCheckCmd)
|
||||
|
||||
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
|
||||
cmd.Flags().String("state-dir", "", "State directory for cache files")
|
||||
@@ -162,3 +170,12 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
log.Fatalf("Timeout waiting for theme generation")
|
||||
}
|
||||
}
|
||||
|
||||
func runMatugenCheck(cmd *cobra.Command, args []string) {
|
||||
checks := matugen.CheckTemplates(nil)
|
||||
data, err := json.Marshal(checks)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal check results: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
68
core/cmd/dms/commands_notify.go
Normal file
68
core/cmd/dms/commands_notify.go
Normal file
@@ -0,0 +1,68 @@
|
||||
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(¬ifyAppName, "app", "DMS", "Application name")
|
||||
notifyCmd.Flags().StringVar(¬ifyIcon, "icon", "", "Icon name or path")
|
||||
notifyCmd.Flags().StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)")
|
||||
notifyCmd.Flags().IntVar(¬ifyTimeout, "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)
|
||||
}
|
||||
}
|
||||
@@ -186,8 +186,10 @@ func runShellInteractive(session bool) {
|
||||
|
||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||
if os.Getenv("QT_LOGGING_RULES") == "" {
|
||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||
}
|
||||
}
|
||||
|
||||
if isSessionManaged && hasSystemdRun() {
|
||||
@@ -424,8 +426,10 @@ func runShellDaemon(session bool) {
|
||||
|
||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||
if os.Getenv("QT_LOGGING_RULES") == "" {
|
||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||
}
|
||||
}
|
||||
|
||||
if isSessionManaged && hasSystemdRun() {
|
||||
|
||||
34
core/go.mod
34
core/go.mod
@@ -4,33 +4,35 @@ go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.17.2
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/godbus/dbus/v5 v5.2.0
|
||||
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-20250930225324-bf4099d4614a
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/image v0.35.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.6.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.7.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // 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-20251126203821-7f9c95185ee0 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc // 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
|
||||
@@ -38,21 +40,23 @@ 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.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.2 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.4 // 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-20251128074608-48f817f57805
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6
|
||||
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
|
||||
@@ -66,7 +70,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.38.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/text v0.33.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
92
core/go.sum
92
core/go.sum
@@ -4,6 +4,14 @@ 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.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI=
|
||||
github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/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=
|
||||
@@ -16,36 +24,38 @@ 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=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.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.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
|
||||
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
|
||||
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.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
||||
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
|
||||
github.com/clipperhouse/displaywidth v0.7.0/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.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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
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/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=
|
||||
@@ -56,22 +66,24 @@ 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-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-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
|
||||
github.com/go-git/go-git/v6 v6.0.0-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-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.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146/go.mod h1:QE/75B8tBSLNGyUUbA9tw3EGHoFtYOtypa2h8YJxsWI=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6 h1:Yo1MlE8LpvD0pr7mZ04b6hKZKQcPvLrQFgyY1jNMEyU=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6/go.mod h1:enMzPHv+9hL4B7tH7OJGQKNzCkMzXovUoaiXfsLF7Xs=
|
||||
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=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/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=
|
||||
@@ -112,14 +124,14 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/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-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=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -127,35 +139,43 @@ 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=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
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/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=
|
||||
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/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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.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/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
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=
|
||||
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=
|
||||
|
||||
@@ -55,7 +55,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
|
||||
return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize)
|
||||
}
|
||||
|
||||
dbPath, err := getDBPath()
|
||||
dbPath, err := GetDBPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get db path: %w", err)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
|
||||
})
|
||||
}
|
||||
|
||||
func getDBPath() (string, error) {
|
||||
func GetDBPath() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
@@ -121,12 +121,31 @@ func getDBPath() (string, error) {
|
||||
cacheDir = filepath.Join(homeDir, ".cache")
|
||||
}
|
||||
|
||||
dbDir := filepath.Join(cacheDir, "dms-clipboard")
|
||||
if err := os.MkdirAll(dbDir, 0700); err != nil {
|
||||
return "", err
|
||||
newDir := filepath.Join(cacheDir, "DankMaterialShell", "clipboard")
|
||||
newPath := filepath.Join(newDir, "db")
|
||||
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
return newPath, nil
|
||||
}
|
||||
|
||||
return filepath.Join(dbDir, "db"), nil
|
||||
oldDir := filepath.Join(cacheDir, "dms-clipboard")
|
||||
oldPath := filepath.Join(oldDir, "db")
|
||||
|
||||
if _, err := os.Stat(oldPath); err == nil {
|
||||
if err := os.MkdirAll(newDir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
os.Remove(oldDir)
|
||||
return newPath, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(newDir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newPath, nil
|
||||
}
|
||||
|
||||
func deduplicateInTx(b *bolt.Bucket, hash uint64) error {
|
||||
|
||||
@@ -176,7 +176,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
|
||||
}
|
||||
|
||||
if existingConfig != "" {
|
||||
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig)
|
||||
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig, dmsDir)
|
||||
if err != nil {
|
||||
cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err))
|
||||
} else {
|
||||
@@ -209,6 +209,8 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
|
||||
{"layout.kdl", NiriLayoutConfig},
|
||||
{"alttab.kdl", NiriAlttabConfig},
|
||||
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||
{"outputs.kdl", ""},
|
||||
{"cursor.kdl", ""},
|
||||
}
|
||||
|
||||
for _, cfg := range configs {
|
||||
@@ -421,24 +423,31 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// mergeNiriOutputSections extracts output sections from existing config and merges them into the new config
|
||||
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig string) (string, error) {
|
||||
// Regular expression to match output sections (including commented ones)
|
||||
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dmsDir string) (string, error) {
|
||||
outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
||||
|
||||
// Find all output sections in the existing config
|
||||
existingOutputs := outputRegex.FindAllString(existingConfig, -1)
|
||||
|
||||
if len(existingOutputs) == 0 {
|
||||
// No output sections to merge
|
||||
return newConfig, nil
|
||||
}
|
||||
|
||||
// Remove the example output section from the new config
|
||||
outputsPath := filepath.Join(dmsDir, "outputs.kdl")
|
||||
if _, err := os.Stat(outputsPath); err != nil {
|
||||
var outputsContent strings.Builder
|
||||
for _, output := range existingOutputs {
|
||||
outputsContent.WriteString(output)
|
||||
outputsContent.WriteString("\n\n")
|
||||
}
|
||||
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 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")
|
||||
}
|
||||
}
|
||||
|
||||
exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
||||
mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "")
|
||||
|
||||
// Find where to insert the output sections (after the input section)
|
||||
inputEndRegex := regexp.MustCompile(`(?m)^}$`)
|
||||
inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1)
|
||||
|
||||
@@ -446,7 +455,6 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig stri
|
||||
return "", fmt.Errorf("could not find insertion point for output sections")
|
||||
}
|
||||
|
||||
// Insert after the first closing brace (end of input section)
|
||||
insertPos := inputMatches[0][1]
|
||||
|
||||
var builder strings.Builder
|
||||
@@ -476,6 +484,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
dmsDir := filepath.Join(configDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0755); err != nil {
|
||||
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
var existingConfig string
|
||||
if _, err := os.Stat(result.Path); err == nil {
|
||||
cd.log("Found existing Hyprland configuration")
|
||||
@@ -515,7 +529,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
}
|
||||
|
||||
if existingConfig != "" {
|
||||
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig)
|
||||
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir)
|
||||
if err != nil {
|
||||
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
|
||||
} else {
|
||||
@@ -529,13 +543,44 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
|
||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
result.Deployed = true
|
||||
cd.log("Successfully deployed Hyprland configuration")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mergeHyprlandMonitorSections extracts monitor sections from existing config and merges them into the new config
|
||||
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig string) (string, error) {
|
||||
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
|
||||
configs := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"colors.conf", HyprColorsConfig},
|
||||
{"layout.conf", HyprLayoutConfig},
|
||||
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||
{"outputs.conf", ""},
|
||||
{"cursor.conf", ""},
|
||||
}
|
||||
|
||||
for _, cfg := range configs {
|
||||
path := filepath.Join(dmsDir, cfg.name)
|
||||
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), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||
}
|
||||
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
|
||||
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
|
||||
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
|
||||
|
||||
@@ -543,6 +588,20 @@ func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig
|
||||
return newConfig, nil
|
||||
}
|
||||
|
||||
outputsPath := filepath.Join(dmsDir, "outputs.conf")
|
||||
if _, err := os.Stat(outputsPath); err != nil {
|
||||
var outputsContent strings.Builder
|
||||
for _, monitor := range existingMonitors {
|
||||
outputsContent.WriteString(monitor)
|
||||
outputsContent.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 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")
|
||||
}
|
||||
}
|
||||
|
||||
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
|
||||
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
|
||||
|
||||
|
||||
@@ -161,7 +161,8 @@ layout {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig)
|
||||
tmpDir := t.TempDir()
|
||||
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig, tmpDir)
|
||||
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
@@ -362,7 +363,8 @@ input {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig)
|
||||
tmpDir := t.TempDir()
|
||||
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
|
||||
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
@@ -406,7 +408,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
||||
content, err := os.ReadFile(result.Path)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "# MONITOR CONFIG")
|
||||
assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty")
|
||||
assert.Contains(t, string(content), "source = ./dms/binds.conf")
|
||||
assert.Contains(t, string(content), "exec-once = ")
|
||||
})
|
||||
|
||||
@@ -442,7 +444,7 @@ general {
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
|
||||
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
|
||||
assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty")
|
||||
assert.Contains(t, string(newContent), "source = ./dms/binds.conf")
|
||||
assert.NotContains(t, string(newContent), "monitor = eDP-2")
|
||||
})
|
||||
}
|
||||
@@ -459,10 +461,7 @@ func TestHyprlandConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
|
||||
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
|
||||
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
|
||||
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
|
||||
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec, {{TERMINAL_COMMAND}}")
|
||||
assert.Contains(t, HyprlandConfig, "bind = $mod, space, exec, dms ipc call spotlight toggle")
|
||||
assert.Contains(t, HyprlandConfig, "windowrule = border_size 0, match:class ^(com\\.mitchellh\\.ghostty)$")
|
||||
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf")
|
||||
}
|
||||
|
||||
func TestGhosttyConfigStructure(t *testing.T) {
|
||||
|
||||
156
core/internal/config/embedded/hypr-binds.conf
Normal file
156
core/internal/config/embedded/hypr-binds.conf
Normal file
@@ -0,0 +1,156 @@
|
||||
# === Application Launchers ===
|
||||
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
|
||||
bind = SUPER, space, exec, dms ipc call spotlight toggle
|
||||
bind = SUPER, V, exec, dms ipc call clipboard toggle
|
||||
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
|
||||
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
|
||||
bind = SUPER, N, exec, dms ipc call notifications toggle
|
||||
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
|
||||
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
|
||||
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
|
||||
bind = SUPER, X, exec, dms ipc call powermenu toggle
|
||||
|
||||
# === Cheat sheet
|
||||
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
|
||||
|
||||
# === Security ===
|
||||
bind = SUPER ALT, L, exec, dms ipc call lock lock
|
||||
bind = SUPER SHIFT, E, exit
|
||||
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
|
||||
|
||||
# === Audio Controls ===
|
||||
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
|
||||
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
|
||||
bindl = , XF86AudioMute, exec, dms ipc call audio mute
|
||||
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
|
||||
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
||||
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
||||
|
||||
# === Brightness Controls ===
|
||||
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
||||
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
|
||||
|
||||
# === Window Management ===
|
||||
bind = SUPER, Q, killactive
|
||||
bind = SUPER, F, fullscreen, 1
|
||||
bind = SUPER SHIFT, F, fullscreen, 0
|
||||
bind = SUPER SHIFT, T, togglefloating
|
||||
bind = SUPER, W, togglegroup
|
||||
|
||||
# === Focus Navigation ===
|
||||
bind = SUPER, left, movefocus, l
|
||||
bind = SUPER, down, movefocus, d
|
||||
bind = SUPER, up, movefocus, u
|
||||
bind = SUPER, right, movefocus, r
|
||||
bind = SUPER, H, movefocus, l
|
||||
bind = SUPER, J, movefocus, d
|
||||
bind = SUPER, K, movefocus, u
|
||||
bind = SUPER, L, movefocus, r
|
||||
|
||||
# === Window Movement ===
|
||||
bind = SUPER SHIFT, left, movewindow, l
|
||||
bind = SUPER SHIFT, down, movewindow, d
|
||||
bind = SUPER SHIFT, up, movewindow, u
|
||||
bind = SUPER SHIFT, right, movewindow, r
|
||||
bind = SUPER SHIFT, H, movewindow, l
|
||||
bind = SUPER SHIFT, J, movewindow, d
|
||||
bind = SUPER SHIFT, K, movewindow, u
|
||||
bind = SUPER SHIFT, L, movewindow, r
|
||||
|
||||
# === Column Navigation ===
|
||||
bind = SUPER, Home, focuswindow, first
|
||||
bind = SUPER, End, focuswindow, last
|
||||
|
||||
# === Monitor Navigation ===
|
||||
bind = SUPER CTRL, left, focusmonitor, l
|
||||
bind = SUPER CTRL, right, focusmonitor, r
|
||||
bind = SUPER CTRL, H, focusmonitor, l
|
||||
bind = SUPER CTRL, J, focusmonitor, d
|
||||
bind = SUPER CTRL, K, focusmonitor, u
|
||||
bind = SUPER CTRL, L, focusmonitor, r
|
||||
|
||||
# === Move to Monitor ===
|
||||
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
|
||||
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
|
||||
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
|
||||
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
|
||||
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
|
||||
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
|
||||
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
|
||||
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
|
||||
|
||||
# === Workspace Navigation ===
|
||||
bind = SUPER, Page_Down, workspace, e+1
|
||||
bind = SUPER, Page_Up, workspace, e-1
|
||||
bind = SUPER, U, workspace, e+1
|
||||
bind = SUPER, I, workspace, e-1
|
||||
bind = SUPER CTRL, down, movetoworkspace, e+1
|
||||
bind = SUPER CTRL, up, movetoworkspace, e-1
|
||||
bind = SUPER CTRL, U, movetoworkspace, e+1
|
||||
bind = SUPER CTRL, I, movetoworkspace, e-1
|
||||
|
||||
# === Move Workspaces ===
|
||||
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
|
||||
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
|
||||
bind = SUPER SHIFT, U, movetoworkspace, e+1
|
||||
bind = SUPER SHIFT, I, movetoworkspace, e-1
|
||||
|
||||
# === Mouse Wheel Navigation ===
|
||||
bind = SUPER, mouse_down, workspace, e+1
|
||||
bind = SUPER, mouse_up, workspace, e-1
|
||||
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
|
||||
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
|
||||
|
||||
# === Numbered Workspaces ===
|
||||
bind = SUPER, 1, workspace, 1
|
||||
bind = SUPER, 2, workspace, 2
|
||||
bind = SUPER, 3, workspace, 3
|
||||
bind = SUPER, 4, workspace, 4
|
||||
bind = SUPER, 5, workspace, 5
|
||||
bind = SUPER, 6, workspace, 6
|
||||
bind = SUPER, 7, workspace, 7
|
||||
bind = SUPER, 8, workspace, 8
|
||||
bind = SUPER, 9, workspace, 9
|
||||
|
||||
# === Move to Numbered Workspaces ===
|
||||
bind = SUPER SHIFT, 1, movetoworkspace, 1
|
||||
bind = SUPER SHIFT, 2, movetoworkspace, 2
|
||||
bind = SUPER SHIFT, 3, movetoworkspace, 3
|
||||
bind = SUPER SHIFT, 4, movetoworkspace, 4
|
||||
bind = SUPER SHIFT, 5, movetoworkspace, 5
|
||||
bind = SUPER SHIFT, 6, movetoworkspace, 6
|
||||
bind = SUPER SHIFT, 7, movetoworkspace, 7
|
||||
bind = SUPER SHIFT, 8, movetoworkspace, 8
|
||||
bind = SUPER SHIFT, 9, movetoworkspace, 9
|
||||
|
||||
# === Column Management ===
|
||||
bind = SUPER, bracketleft, layoutmsg, preselect l
|
||||
bind = SUPER, bracketright, layoutmsg, preselect r
|
||||
|
||||
# === Sizing & Layout ===
|
||||
bind = SUPER, R, layoutmsg, togglesplit
|
||||
bind = SUPER CTRL, F, resizeactive, exact 100%
|
||||
|
||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||
bindmd = SUPER, mouse:272, Move window, movewindow
|
||||
bindmd = SUPER, mouse:273, Resize window, resizewindow
|
||||
|
||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
|
||||
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
|
||||
|
||||
# === Manual Sizing ===
|
||||
binde = SUPER, minus, resizeactive, -10% 0
|
||||
binde = SUPER, equal, resizeactive, 10% 0
|
||||
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
|
||||
binde = SUPER SHIFT, equal, resizeactive, 0 10%
|
||||
|
||||
# === Screenshots ===
|
||||
bind = , Print, exec, dms screenshot
|
||||
bind = CTRL, Print, exec, dms screenshot full
|
||||
bind = ALT, Print, exec, dms screenshot window
|
||||
|
||||
# === System Controls ===
|
||||
bind = SUPER SHIFT, P, dpms, toggle
|
||||
25
core/internal/config/embedded/hypr-colors.conf
Normal file
25
core/internal/config/embedded/hypr-colors.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
# ! Auto-generated file. Do not edit directly.
|
||||
# Remove source = ./dms/colors.conf from your config to override.
|
||||
|
||||
$primary = rgb(d0bcff)
|
||||
$outline = rgb(948f99)
|
||||
$error = rgb(f2b8b5)
|
||||
|
||||
general {
|
||||
col.active_border = $primary
|
||||
col.inactive_border = $outline
|
||||
}
|
||||
|
||||
group {
|
||||
col.border_active = $primary
|
||||
col.border_inactive = $outline
|
||||
col.border_locked_active = $error
|
||||
col.border_locked_inactive = $outline
|
||||
|
||||
groupbar {
|
||||
col.active = $primary
|
||||
col.inactive = $outline
|
||||
col.locked_active = $error
|
||||
col.locked_inactive = $outline
|
||||
}
|
||||
}
|
||||
11
core/internal/config/embedded/hypr-layout.conf
Normal file
11
core/internal/config/embedded/hypr-layout.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
# Auto-generated by DMS - do not edit manually
|
||||
|
||||
general {
|
||||
gaps_in = 4
|
||||
gaps_out = 4
|
||||
border_size = 2
|
||||
}
|
||||
|
||||
decoration {
|
||||
rounding = 12
|
||||
}
|
||||
@@ -27,10 +27,7 @@ input {
|
||||
general {
|
||||
gaps_in = 5
|
||||
gaps_out = 5
|
||||
border_size = 0 # off in niri
|
||||
|
||||
col.active_border = rgba(707070ff)
|
||||
col.inactive_border = rgba(d0d0d0ff)
|
||||
border_size = 2
|
||||
|
||||
layout = dwindle
|
||||
}
|
||||
@@ -42,7 +39,7 @@ decoration {
|
||||
rounding = 12
|
||||
|
||||
active_opacity = 1.0
|
||||
inactive_opacity = 0.9
|
||||
inactive_opacity = 1.0
|
||||
|
||||
shadow {
|
||||
enabled = true
|
||||
@@ -93,7 +90,6 @@ misc {
|
||||
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
|
||||
|
||||
windowrule = rounding 12, match:class ^(org\.gnome\.)
|
||||
windowrule = border_size 0, match:class ^(org\.gnome\.)
|
||||
|
||||
windowrule = tile on, match:class ^(gnome-control-center)$
|
||||
windowrule = tile on, match:class ^(pavucontrol)$
|
||||
@@ -106,178 +102,17 @@ windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
||||
windowrule = float on, match:class ^(steam)$
|
||||
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
||||
|
||||
windowrule = border_size 0, match:class ^(org\.wezfurlong\.wezterm)$
|
||||
windowrule = border_size 0, match:class ^(Alacritty)$
|
||||
windowrule = border_size 0, match:class ^(zen)$
|
||||
windowrule = border_size 0, match:class ^(com\.mitchellh\.ghostty)$
|
||||
windowrule = border_size 0, match:class ^(kitty)$
|
||||
|
||||
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
||||
windowrule = float on, match:class ^(zoom)$
|
||||
|
||||
# DMS windows floating by default
|
||||
windowrule = float on, match:class ^(org.quickshell)$
|
||||
windowrule = opacity 0.9 0.9, match:float false, match:focus false
|
||||
# ! Hyprland doesn't size these windows correctly so disabling by default here
|
||||
# windowrule = float on, match:class ^(org.quickshell)$
|
||||
|
||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
||||
|
||||
# ==================
|
||||
# KEYBINDINGS
|
||||
# ==================
|
||||
$mod = SUPER
|
||||
|
||||
# === Application Launchers ===
|
||||
bind = $mod, T, exec, {{TERMINAL_COMMAND}}
|
||||
bind = $mod, space, exec, dms ipc call spotlight toggle
|
||||
bind = $mod, V, exec, dms ipc call clipboard toggle
|
||||
bind = $mod, M, exec, dms ipc call processlist focusOrToggle
|
||||
bind = $mod, comma, exec, dms ipc call settings focusOrToggle
|
||||
bind = $mod, N, exec, dms ipc call notifications toggle
|
||||
bind = $mod SHIFT, N, exec, dms ipc call notepad toggle
|
||||
bind = $mod, Y, exec, dms ipc call dankdash wallpaper
|
||||
bind = $mod, TAB, exec, dms ipc call hypr toggleOverview
|
||||
|
||||
# === Cheat sheet
|
||||
bind = $mod SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
|
||||
|
||||
# === Security ===
|
||||
bind = $mod ALT, L, exec, dms ipc call lock lock
|
||||
bind = $mod SHIFT, E, exit
|
||||
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
|
||||
|
||||
# === Audio Controls ===
|
||||
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
|
||||
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
|
||||
bindl = , XF86AudioMute, exec, dms ipc call audio mute
|
||||
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
|
||||
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
||||
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
||||
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
||||
|
||||
# === Brightness Controls ===
|
||||
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
||||
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
|
||||
|
||||
# === Window Management ===
|
||||
bind = $mod, Q, killactive
|
||||
bind = $mod, F, fullscreen, 1
|
||||
bind = $mod SHIFT, F, fullscreen, 0
|
||||
bind = $mod SHIFT, T, togglefloating
|
||||
bind = $mod, W, togglegroup
|
||||
|
||||
# === Focus Navigation ===
|
||||
bind = $mod, left, movefocus, l
|
||||
bind = $mod, down, movefocus, d
|
||||
bind = $mod, up, movefocus, u
|
||||
bind = $mod, right, movefocus, r
|
||||
bind = $mod, H, movefocus, l
|
||||
bind = $mod, J, movefocus, d
|
||||
bind = $mod, K, movefocus, u
|
||||
bind = $mod, L, movefocus, r
|
||||
|
||||
# === Window Movement ===
|
||||
bind = $mod SHIFT, left, movewindow, l
|
||||
bind = $mod SHIFT, down, movewindow, d
|
||||
bind = $mod SHIFT, up, movewindow, u
|
||||
bind = $mod SHIFT, right, movewindow, r
|
||||
bind = $mod SHIFT, H, movewindow, l
|
||||
bind = $mod SHIFT, J, movewindow, d
|
||||
bind = $mod SHIFT, K, movewindow, u
|
||||
bind = $mod SHIFT, L, movewindow, r
|
||||
|
||||
# === Column Navigation ===
|
||||
bind = $mod, Home, focuswindow, first
|
||||
bind = $mod, End, focuswindow, last
|
||||
|
||||
# === Monitor Navigation ===
|
||||
bind = $mod CTRL, left, focusmonitor, l
|
||||
bind = $mod CTRL, right, focusmonitor, r
|
||||
bind = $mod CTRL, H, focusmonitor, l
|
||||
bind = $mod CTRL, J, focusmonitor, d
|
||||
bind = $mod CTRL, K, focusmonitor, u
|
||||
bind = $mod CTRL, L, focusmonitor, r
|
||||
|
||||
# === Move to Monitor ===
|
||||
bind = $mod SHIFT CTRL, left, movewindow, mon:l
|
||||
bind = $mod SHIFT CTRL, down, movewindow, mon:d
|
||||
bind = $mod SHIFT CTRL, up, movewindow, mon:u
|
||||
bind = $mod SHIFT CTRL, right, movewindow, mon:r
|
||||
bind = $mod SHIFT CTRL, H, movewindow, mon:l
|
||||
bind = $mod SHIFT CTRL, J, movewindow, mon:d
|
||||
bind = $mod SHIFT CTRL, K, movewindow, mon:u
|
||||
bind = $mod SHIFT CTRL, L, movewindow, mon:r
|
||||
|
||||
# === Workspace Navigation ===
|
||||
bind = $mod, Page_Down, workspace, e+1
|
||||
bind = $mod, Page_Up, workspace, e-1
|
||||
bind = $mod, U, workspace, e+1
|
||||
bind = $mod, I, workspace, e-1
|
||||
bind = $mod CTRL, down, movetoworkspace, e+1
|
||||
bind = $mod CTRL, up, movetoworkspace, e-1
|
||||
bind = $mod CTRL, U, movetoworkspace, e+1
|
||||
bind = $mod CTRL, I, movetoworkspace, e-1
|
||||
|
||||
# === Move Workspaces ===
|
||||
bind = $mod SHIFT, Page_Down, movetoworkspace, e+1
|
||||
bind = $mod SHIFT, Page_Up, movetoworkspace, e-1
|
||||
bind = $mod SHIFT, U, movetoworkspace, e+1
|
||||
bind = $mod SHIFT, I, movetoworkspace, e-1
|
||||
|
||||
# === Mouse Wheel Navigation ===
|
||||
bind = $mod, mouse_down, workspace, e+1
|
||||
bind = $mod, mouse_up, workspace, e-1
|
||||
bind = $mod CTRL, mouse_down, movetoworkspace, e+1
|
||||
bind = $mod CTRL, mouse_up, movetoworkspace, e-1
|
||||
|
||||
# === Numbered Workspaces ===
|
||||
bind = $mod, 1, workspace, 1
|
||||
bind = $mod, 2, workspace, 2
|
||||
bind = $mod, 3, workspace, 3
|
||||
bind = $mod, 4, workspace, 4
|
||||
bind = $mod, 5, workspace, 5
|
||||
bind = $mod, 6, workspace, 6
|
||||
bind = $mod, 7, workspace, 7
|
||||
bind = $mod, 8, workspace, 8
|
||||
bind = $mod, 9, workspace, 9
|
||||
|
||||
# === Move to Numbered Workspaces ===
|
||||
bind = $mod SHIFT, 1, movetoworkspace, 1
|
||||
bind = $mod SHIFT, 2, movetoworkspace, 2
|
||||
bind = $mod SHIFT, 3, movetoworkspace, 3
|
||||
bind = $mod SHIFT, 4, movetoworkspace, 4
|
||||
bind = $mod SHIFT, 5, movetoworkspace, 5
|
||||
bind = $mod SHIFT, 6, movetoworkspace, 6
|
||||
bind = $mod SHIFT, 7, movetoworkspace, 7
|
||||
bind = $mod SHIFT, 8, movetoworkspace, 8
|
||||
bind = $mod SHIFT, 9, movetoworkspace, 9
|
||||
|
||||
# === Column Management ===
|
||||
bind = $mod, bracketleft, layoutmsg, preselect l
|
||||
bind = $mod, bracketright, layoutmsg, preselect r
|
||||
|
||||
# === Sizing & Layout ===
|
||||
bind = $mod, R, layoutmsg, togglesplit
|
||||
bind = $mod CTRL, F, resizeactive, exact 100%
|
||||
|
||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||
bindmd = $mod, mouse:272, Move window, movewindow
|
||||
bindmd = $mod, mouse:273, Resize window, resizewindow
|
||||
|
||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||
bindd = $mod, code:20, Expand window left, resizeactive, -100 0
|
||||
bindd = $mod, code:21, Shrink window left, resizeactive, 100 0
|
||||
|
||||
# === Manual Sizing ===
|
||||
binde = $mod, minus, resizeactive, -10% 0
|
||||
binde = $mod, equal, resizeactive, 10% 0
|
||||
binde = $mod SHIFT, minus, resizeactive, 0 -10%
|
||||
binde = $mod SHIFT, equal, resizeactive, 0 10%
|
||||
|
||||
# === Screenshots ===
|
||||
bind = , Print, exec, dms screenshot
|
||||
bind = CTRL, Print, exec, dms screenshot full
|
||||
bind = ALT, Print, exec, dms screenshot window
|
||||
|
||||
# === System Controls ===
|
||||
bind = $mod SHIFT, P, dpms, toggle
|
||||
source = ./dms/colors.conf
|
||||
source = ./dms/outputs.conf
|
||||
source = ./dms/layout.conf
|
||||
source = ./dms/cursor.conf
|
||||
source = ./dms/binds.conf
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// ! DO NOT EDIT !
|
||||
// ! AUTO-GENERATED BY DMS !
|
||||
// ! CHANGES WILL BE OVERWRITTEN !
|
||||
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
|
||||
|
||||
binds {
|
||||
// === System & Overview ===
|
||||
Mod+D repeat=false { toggle-overview; }
|
||||
@@ -20,6 +15,8 @@ binds {
|
||||
Mod+M hotkey-overlay-title="Task Manager" {
|
||||
spawn "dms" "ipc" "call" "processlist" "focusOrToggle";
|
||||
}
|
||||
|
||||
Super+X hotkey-overlay-title="Power Menu: Toggle" { spawn "dms" "ipc" "call" "powermenu" "toggle"; }
|
||||
Mod+Comma hotkey-overlay-title="Settings" {
|
||||
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// ! DO NOT EDIT !
|
||||
// ! AUTO-GENERATED BY DMS !
|
||||
// ! CHANGES WILL BE OVERWRITTEN !
|
||||
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
|
||||
// ! Auto-generated file. Do not edit directly.
|
||||
// Remove `include "dms/colors.kdl"` from your config to override.
|
||||
|
||||
layout {
|
||||
background-color "transparent"
|
||||
|
||||
focus-ring {
|
||||
active-color "#9dcbfb"
|
||||
inactive-color "#8c9199"
|
||||
urgent-color "#ffb4ab"
|
||||
active-color "#d0bcff"
|
||||
inactive-color "#948f99"
|
||||
urgent-color "#f2b8b5"
|
||||
}
|
||||
|
||||
border {
|
||||
active-color "#9dcbfb"
|
||||
inactive-color "#8c9199"
|
||||
urgent-color "#ffb4ab"
|
||||
active-color "#d0bcff"
|
||||
inactive-color "#948f99"
|
||||
urgent-color "#f2b8b5"
|
||||
}
|
||||
|
||||
shadow {
|
||||
@@ -23,19 +21,19 @@ layout {
|
||||
}
|
||||
|
||||
tab-indicator {
|
||||
active-color "#9dcbfb"
|
||||
inactive-color "#8c9199"
|
||||
urgent-color "#ffb4ab"
|
||||
active-color "#d0bcff"
|
||||
inactive-color "#948f99"
|
||||
urgent-color "#f2b8b5"
|
||||
}
|
||||
|
||||
insert-hint {
|
||||
color "#9dcbfb80"
|
||||
color "#d0bcff80"
|
||||
}
|
||||
}
|
||||
|
||||
recent-windows {
|
||||
highlight {
|
||||
active-color "#124a73"
|
||||
urgent-color "#ffb4ab"
|
||||
active-color "#4f378b"
|
||||
urgent-color "#f2b8b5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,10 +240,6 @@ window-rule {
|
||||
match app-id="kitty"
|
||||
draw-border-with-background false
|
||||
}
|
||||
window-rule {
|
||||
match is-active=false
|
||||
opacity 0.9
|
||||
}
|
||||
window-rule {
|
||||
match app-id=r#"firefox$"# title="^Picture-in-Picture$"
|
||||
match app-id="zoom"
|
||||
@@ -273,3 +269,5 @@ include "dms/colors.kdl"
|
||||
include "dms/layout.kdl"
|
||||
include "dms/alttab.kdl"
|
||||
include "dms/binds.kdl"
|
||||
include "dms/outputs.kdl"
|
||||
include "dms/cursor.kdl"
|
||||
|
||||
@@ -4,3 +4,12 @@ import _ "embed"
|
||||
|
||||
//go:embed embedded/hyprland.conf
|
||||
var HyprlandConfig string
|
||||
|
||||
//go:embed embedded/hypr-colors.conf
|
||||
var HyprColorsConfig string
|
||||
|
||||
//go:embed embedded/hypr-layout.conf
|
||||
var HyprLayoutConfig string
|
||||
|
||||
//go:embed embedded/hypr-binds.conf
|
||||
var HyprBindsConfig string
|
||||
|
||||
@@ -199,31 +199,6 @@ 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)
|
||||
@@ -356,6 +331,59 @@ 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
|
||||
@@ -369,6 +397,29 @@ 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)
|
||||
@@ -389,6 +440,9 @@ 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
|
||||
@@ -410,115 +464,136 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
}
|
||||
palette.Color0 = NewColorInfo(bgColor)
|
||||
|
||||
hueShift := (hsv.H - 0.6) * 0.12
|
||||
satBoost := 1.15
|
||||
baseSat := math.Max(ph.S, 0.5)
|
||||
baseVal := math.Max(ph.V, 0.5)
|
||||
|
||||
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))
|
||||
}
|
||||
redH := blendHue(0.0, ph.H, 0.12)
|
||||
greenH := blendHue(0.33, ph.H, 0.10)
|
||||
yellowH := blendHue(0.14, ph.H, 0.04)
|
||||
|
||||
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))
|
||||
accentTarget := secondaryTarget * 0.7
|
||||
|
||||
if opts.IsLight {
|
||||
palette.Color7 = NewColorInfo("#1a1a1a")
|
||||
palette.Color8 = NewColorInfo("#2e2e2e")
|
||||
} else {
|
||||
palette.Color7 = NewColorInfo("#abb2bf")
|
||||
palette.Color8 = NewColorInfo("#5c6370")
|
||||
}
|
||||
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))
|
||||
|
||||
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))
|
||||
}
|
||||
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))
|
||||
|
||||
if opts.IsLight {
|
||||
palette.Color15 = NewColorInfo("#1a1a1a")
|
||||
yellowS := math.Min(baseSat*1.5, 1.0)
|
||||
yellowV := math.Min(baseVal*1.2, 1.0)
|
||||
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
|
||||
|
||||
blueS := math.Min(ph.S*1.05, 1.0)
|
||||
blueV := math.Min(ph.V*1.05, 1.0)
|
||||
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
|
||||
|
||||
// Color5 matches primary_container exactly (light container in light mode)
|
||||
container5 := DeriveContainer(primaryColor, true)
|
||||
palette.Color5 = NewColorInfo(container5)
|
||||
|
||||
palette.Color6 = NewColorInfo(primaryColor)
|
||||
|
||||
gray7S := baseSat * 0.08
|
||||
gray7V := baseVal * 0.28
|
||||
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
||||
|
||||
gray8S := baseSat * 0.05
|
||||
gray8V := baseVal * 0.85
|
||||
dimTarget := secondaryTarget * 0.5
|
||||
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
|
||||
|
||||
brightRedS := math.Min(baseSat*1.0, 1.0)
|
||||
brightRedV := math.Min(baseVal*1.2, 1.0)
|
||||
palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts))
|
||||
|
||||
brightGreenS := math.Min(baseSat*1.1, 1.0)
|
||||
brightGreenV := math.Min(baseVal*1.1, 1.0)
|
||||
palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts))
|
||||
|
||||
brightYellowS := math.Min(baseSat*1.4, 1.0)
|
||||
brightYellowV := math.Min(baseVal*1.3, 1.0)
|
||||
palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts))
|
||||
|
||||
brightBlueS := math.Min(ph.S*1.1, 1.0)
|
||||
brightBlueV := math.Min(ph.V*1.15, 1.0)
|
||||
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
|
||||
|
||||
lightContainer := DeriveContainer(primaryColor, true)
|
||||
palette.Color13 = NewColorInfo(lightContainer)
|
||||
|
||||
brightCyanS := ph.S * 0.5
|
||||
brightCyanV := math.Min(ph.V*1.3, 1.0)
|
||||
palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightCyanS, V: brightCyanV})))
|
||||
|
||||
white15S := baseSat * 0.04
|
||||
white15V := math.Min(baseVal*1.5, 1.0)
|
||||
palette.Color15 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})))
|
||||
} else {
|
||||
palette.Color15 = NewColorInfo("#ffffff")
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
return palette
|
||||
|
||||
@@ -366,10 +366,19 @@ func TestGeneratePalette(t *testing.T) {
|
||||
t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex)
|
||||
}
|
||||
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -579,6 +588,10 @@ 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 {
|
||||
|
||||
@@ -153,7 +153,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
|
||||
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||
|
||||
@@ -108,7 +108,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
packages := map[string]PackageMapping{
|
||||
// Standard zypper packages
|
||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
@@ -117,6 +116,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
// DMS packages from OBS
|
||||
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
@@ -2,45 +2,93 @@ package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type HyprlandProvider struct {
|
||||
configPath string
|
||||
configPath string
|
||||
dmsBindsIncluded bool
|
||||
parsed bool
|
||||
}
|
||||
|
||||
func NewHyprlandProvider(configPath string) *HyprlandProvider {
|
||||
if configPath == "" {
|
||||
configPath = "$HOME/.config/hypr"
|
||||
configPath = defaultHyprlandConfigDir()
|
||||
}
|
||||
return &HyprlandProvider{
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultHyprlandConfigDir() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "hypr")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) Name() string {
|
||||
return "hyprland"
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
section, err := ParseHyprlandKeys(h.configPath)
|
||||
result, err := ParseHyprlandKeysWithDMS(h.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse hyprland config: %w", err)
|
||||
}
|
||||
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
h.convertSection(section, "", categorizedBinds)
|
||||
h.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
h.parsed = true
|
||||
|
||||
return &keybinds.CheatSheet{
|
||||
Title: "Hyprland Keybinds",
|
||||
Provider: h.Name(),
|
||||
Binds: categorizedBinds,
|
||||
}, nil
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
|
||||
|
||||
sheet := &keybinds.CheatSheet{
|
||||
Title: "Hyprland Keybinds",
|
||||
Provider: h.Name(),
|
||||
Binds: categorizedBinds,
|
||||
DMSBindsIncluded: result.DMSBindsIncluded,
|
||||
}
|
||||
|
||||
if result.DMSStatus != nil {
|
||||
sheet.DMSStatus = &keybinds.DMSBindsStatus{
|
||||
Exists: result.DMSStatus.Exists,
|
||||
Included: result.DMSStatus.Included,
|
||||
IncludePosition: result.DMSStatus.IncludePosition,
|
||||
TotalIncludes: result.DMSStatus.TotalIncludes,
|
||||
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
|
||||
Effective: result.DMSStatus.Effective,
|
||||
OverriddenBy: result.DMSStatus.OverriddenBy,
|
||||
StatusMessage: result.DMSStatus.StatusMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
|
||||
func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
|
||||
if h.parsed {
|
||||
return h.dmsBindsIncluded
|
||||
}
|
||||
|
||||
result, err := ParseHyprlandKeysWithDMS(h.configPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
h.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
h.parsed = true
|
||||
return h.dmsBindsIncluded
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
|
||||
currentSubcat := subcategory
|
||||
if section.Name != "" {
|
||||
currentSubcat = section.Name
|
||||
@@ -48,12 +96,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
|
||||
|
||||
for _, kb := range section.Keybinds {
|
||||
category := h.categorizeByDispatcher(kb.Dispatcher)
|
||||
bind := h.convertKeybind(&kb, currentSubcat)
|
||||
bind := h.convertKeybind(&kb, currentSubcat, conflicts)
|
||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||
}
|
||||
|
||||
for _, child := range section.Children {
|
||||
h.convertSection(&child, currentSubcat, categorizedBinds)
|
||||
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +133,8 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string) keybinds.Keybind {
|
||||
key := h.formatKey(kb)
|
||||
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
|
||||
keyStr := h.formatKey(kb)
|
||||
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
|
||||
desc := kb.Comment
|
||||
|
||||
@@ -94,12 +142,33 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
|
||||
desc = rawAction
|
||||
}
|
||||
|
||||
return keybinds.Keybind{
|
||||
Key: key,
|
||||
source := "config"
|
||||
if strings.Contains(kb.Source, "dms/binds.conf") {
|
||||
source = "dms"
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: desc,
|
||||
Action: rawAction,
|
||||
Subcategory: subcategory,
|
||||
Source: source,
|
||||
Flags: kb.Flags,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||
bind.Conflict = &keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: conflictKb.Comment,
|
||||
Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params),
|
||||
Source: "config",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bind
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string {
|
||||
@@ -115,3 +184,314 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) GetOverridePath() string {
|
||||
expanded, err := utils.ExpandPath(h.configPath)
|
||||
if err != nil {
|
||||
return filepath.Join(h.configPath, "dms", "binds.conf")
|
||||
}
|
||||
return filepath.Join(expanded, "dms", "binds.conf")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) validateAction(action string) error {
|
||||
action = strings.TrimSpace(action)
|
||||
switch {
|
||||
case action == "":
|
||||
return fmt.Errorf("action cannot be empty")
|
||||
case action == "exec" || action == "exec ":
|
||||
return fmt.Errorf("exec dispatcher requires arguments")
|
||||
case strings.HasPrefix(action, "exec "):
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(action, "exec "))
|
||||
if rest == "" {
|
||||
return fmt.Errorf("exec dispatcher requires arguments")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
|
||||
if err := h.validateAction(action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
overridePath := h.GetOverridePath()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||
}
|
||||
|
||||
existingBinds, err := h.loadOverrideBinds()
|
||||
if err != nil {
|
||||
existingBinds = make(map[string]*hyprlandOverrideBind)
|
||||
}
|
||||
|
||||
// Extract flags from options
|
||||
var flags string
|
||||
if options != nil {
|
||||
if f, ok := options["flags"].(string); ok {
|
||||
flags = f
|
||||
}
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: key,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
Options: options,
|
||||
}
|
||||
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) RemoveBind(key string) error {
|
||||
existingBinds, err := h.loadOverrideBinds()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
delete(existingBinds, normalizedKey)
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
type hyprlandOverrideBind struct {
|
||||
Key string
|
||||
Action string
|
||||
Description string
|
||||
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
|
||||
overridePath := h.GetOverridePath()
|
||||
binds := make(map[string]*hyprlandOverrideBind)
|
||||
|
||||
data, err := os.ReadFile(overridePath)
|
||||
if os.IsNotExist(err) {
|
||||
return binds, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(line, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract flags from bind type
|
||||
bindType := strings.TrimSpace(parts[0])
|
||||
flags := extractBindFlags(bindType)
|
||||
hasDescFlag := strings.Contains(flags, "d")
|
||||
|
||||
content := strings.TrimSpace(parts[1])
|
||||
commentParts := strings.SplitN(content, "#", 2)
|
||||
bindContent := strings.TrimSpace(commentParts[0])
|
||||
|
||||
var comment string
|
||||
if len(commentParts) > 1 {
|
||||
comment = strings.TrimSpace(commentParts[1])
|
||||
}
|
||||
|
||||
// For bindd, format is: mods, key, description, dispatcher, params
|
||||
var minFields, descIndex, dispatcherIndex int
|
||||
if hasDescFlag {
|
||||
minFields = 4
|
||||
descIndex = 2
|
||||
dispatcherIndex = 3
|
||||
} else {
|
||||
minFields = 3
|
||||
dispatcherIndex = 2
|
||||
}
|
||||
|
||||
fields := strings.SplitN(bindContent, ",", minFields+2)
|
||||
if len(fields) < minFields {
|
||||
continue
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(fields[0])
|
||||
keyName := strings.TrimSpace(fields[1])
|
||||
|
||||
var dispatcher, params string
|
||||
if hasDescFlag {
|
||||
if comment == "" {
|
||||
comment = strings.TrimSpace(fields[descIndex])
|
||||
}
|
||||
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
|
||||
if len(fields) > dispatcherIndex+1 {
|
||||
paramParts := fields[dispatcherIndex+1:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
} else {
|
||||
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
|
||||
if len(fields) > dispatcherIndex+1 {
|
||||
paramParts := fields[dispatcherIndex+1:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
}
|
||||
|
||||
keyStr := h.buildKeyString(mods, keyName)
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
action := dispatcher
|
||||
if params != "" {
|
||||
action = dispatcher + " " + params
|
||||
}
|
||||
|
||||
binds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: keyStr,
|
||||
Action: action,
|
||||
Description: comment,
|
||||
Flags: flags,
|
||||
}
|
||||
}
|
||||
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
|
||||
if mods == "" {
|
||||
return key
|
||||
}
|
||||
|
||||
modList := strings.FieldsFunc(mods, func(r rune) bool {
|
||||
return r == '+' || r == ' '
|
||||
})
|
||||
|
||||
parts := append(modList, key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) getBindSortPriority(action string) int {
|
||||
switch {
|
||||
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
|
||||
return 0
|
||||
case strings.Contains(action, "workspace"):
|
||||
return 1
|
||||
case strings.Contains(action, "window") || strings.Contains(action, "focus") ||
|
||||
strings.Contains(action, "move") || strings.Contains(action, "swap") ||
|
||||
strings.Contains(action, "resize"):
|
||||
return 2
|
||||
case strings.Contains(action, "monitor"):
|
||||
return 3
|
||||
case strings.HasPrefix(action, "exec"):
|
||||
return 4
|
||||
case action == "exit" || strings.Contains(action, "dpms"):
|
||||
return 5
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error {
|
||||
overridePath := h.GetOverridePath()
|
||||
content := h.generateBindsContent(binds)
|
||||
return os.WriteFile(overridePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string {
|
||||
if len(binds) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
bindList := make([]*hyprlandOverrideBind, 0, len(binds))
|
||||
for _, bind := range binds {
|
||||
bindList = append(bindList, bind)
|
||||
}
|
||||
|
||||
sort.Slice(bindList, func(i, j int) bool {
|
||||
pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return bindList[i].Key < bindList[j].Key
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
for _, bind := range bindList {
|
||||
h.writeBindLine(&sb, bind)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||
mods, key := h.parseKeyString(bind.Key)
|
||||
dispatcher, params := h.parseAction(bind.Action)
|
||||
|
||||
// Write bind type with flags (e.g., "bind", "binde", "bindel")
|
||||
sb.WriteString("bind")
|
||||
if bind.Flags != "" {
|
||||
sb.WriteString(bind.Flags)
|
||||
}
|
||||
sb.WriteString(" = ")
|
||||
sb.WriteString(mods)
|
||||
sb.WriteString(", ")
|
||||
sb.WriteString(key)
|
||||
sb.WriteString(", ")
|
||||
|
||||
// For bindd (description flag), include description before dispatcher
|
||||
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
|
||||
sb.WriteString(bind.Description)
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
|
||||
sb.WriteString(dispatcher)
|
||||
|
||||
if params != "" {
|
||||
sb.WriteString(", ")
|
||||
sb.WriteString(params)
|
||||
}
|
||||
|
||||
// Only add comment if not using bindd (which has inline description)
|
||||
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
|
||||
sb.WriteString(" # ")
|
||||
sb.WriteString(bind.Description)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
|
||||
parts := strings.Split(keyStr, "+")
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return "", keyStr
|
||||
case 1:
|
||||
return "", parts[0]
|
||||
default:
|
||||
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
|
||||
parts := strings.SplitN(action, " ", 2)
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return action, ""
|
||||
case 1:
|
||||
dispatcher = parts[0]
|
||||
default:
|
||||
dispatcher = parts[0]
|
||||
params = parts[1]
|
||||
}
|
||||
|
||||
// Convert internal spawn format to Hyprland's exec
|
||||
if dispatcher == "spawn" {
|
||||
dispatcher = "exec"
|
||||
}
|
||||
|
||||
return dispatcher, params
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ type HyprlandKeyBinding struct {
|
||||
Dispatcher string `json:"dispatcher"`
|
||||
Params string `json:"params"`
|
||||
Comment string `json:"comment"`
|
||||
Source string `json:"source"`
|
||||
Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
|
||||
}
|
||||
|
||||
type HyprlandSection struct {
|
||||
@@ -32,14 +34,36 @@ type HyprlandSection struct {
|
||||
}
|
||||
|
||||
type HyprlandParser struct {
|
||||
contentLines []string
|
||||
readingLine int
|
||||
contentLines []string
|
||||
readingLine int
|
||||
configDir string
|
||||
currentSource string
|
||||
dmsBindsExists bool
|
||||
dmsBindsIncluded bool
|
||||
includeCount int
|
||||
dmsIncludePos int
|
||||
bindsAfterDMS int
|
||||
dmsBindKeys map[string]bool
|
||||
configBindKeys map[string]bool
|
||||
conflictingConfigs map[string]*HyprlandKeyBinding
|
||||
bindMap map[string]*HyprlandKeyBinding
|
||||
bindOrder []string
|
||||
processedFiles map[string]bool
|
||||
dmsProcessed bool
|
||||
}
|
||||
|
||||
func NewHyprlandParser() *HyprlandParser {
|
||||
func NewHyprlandParser(configDir string) *HyprlandParser {
|
||||
return &HyprlandParser{
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
configDir: configDir,
|
||||
dmsIncludePos: -1,
|
||||
dmsBindKeys: make(map[string]bool),
|
||||
configBindKeys: make(map[string]bool),
|
||||
conflictingConfigs: make(map[string]*HyprlandKeyBinding),
|
||||
bindMap: make(map[string]*HyprlandKeyBinding),
|
||||
bindOrder: []string{},
|
||||
processedFiles: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,71 +219,7 @@ func hyprlandAutogenerateComment(dispatcher, params string) string {
|
||||
|
||||
func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding {
|
||||
line := p.contentLines[lineNumber]
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := parts[1]
|
||||
keyParts := strings.SplitN(keys, "#", 2)
|
||||
keys = keyParts[0]
|
||||
|
||||
var comment string
|
||||
if len(keyParts) > 1 {
|
||||
comment = strings.TrimSpace(keyParts[1])
|
||||
}
|
||||
|
||||
keyFields := strings.SplitN(keys, ",", 5)
|
||||
if len(keyFields) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(keyFields[0])
|
||||
key := strings.TrimSpace(keyFields[1])
|
||||
dispatcher := strings.TrimSpace(keyFields[2])
|
||||
|
||||
var params string
|
||||
if len(keyFields) > 3 {
|
||||
paramParts := keyFields[3:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
|
||||
if comment != "" {
|
||||
if strings.HasPrefix(comment, HideComment) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
comment = hyprlandAutogenerateComment(dispatcher, params)
|
||||
}
|
||||
|
||||
var modList []string
|
||||
if mods != "" {
|
||||
modstring := mods + string(ModSeparators[0])
|
||||
p := 0
|
||||
for index, char := range modstring {
|
||||
isModSep := false
|
||||
for _, sep := range ModSeparators {
|
||||
if char == sep {
|
||||
isModSep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isModSep {
|
||||
if index-p > 1 {
|
||||
modList = append(modList, modstring[p:index])
|
||||
}
|
||||
p = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &HyprlandKeyBinding{
|
||||
Mods: modList,
|
||||
Key: key,
|
||||
Dispatcher: dispatcher,
|
||||
Params: params,
|
||||
Comment: comment,
|
||||
}
|
||||
return p.parseBindLine(line)
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection {
|
||||
@@ -320,9 +280,348 @@ func (p *HyprlandParser) ParseKeys() *HyprlandSection {
|
||||
}
|
||||
|
||||
func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser(path)
|
||||
if err := parser.ReadContent(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parser.ParseKeys(), nil
|
||||
}
|
||||
|
||||
type HyprlandParseResult struct {
|
||||
Section *HyprlandSection
|
||||
DMSBindsIncluded bool
|
||||
DMSStatus *HyprlandDMSStatus
|
||||
ConflictingConfigs map[string]*HyprlandKeyBinding
|
||||
}
|
||||
|
||||
type HyprlandDMSStatus struct {
|
||||
Exists bool
|
||||
Included bool
|
||||
IncludePosition int
|
||||
TotalIncludes int
|
||||
BindsAfterDMS int
|
||||
Effective bool
|
||||
OverriddenBy int
|
||||
StatusMessage string
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
|
||||
status := &HyprlandDMSStatus{
|
||||
Exists: p.dmsBindsExists,
|
||||
Included: p.dmsBindsIncluded,
|
||||
IncludePosition: p.dmsIncludePos,
|
||||
TotalIncludes: p.includeCount,
|
||||
BindsAfterDMS: p.bindsAfterDMS,
|
||||
}
|
||||
|
||||
switch {
|
||||
case !p.dmsBindsExists:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf does not exist"
|
||||
case !p.dmsBindsIncluded:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf is not sourced in config"
|
||||
case p.bindsAfterDMS > 0:
|
||||
status.Effective = true
|
||||
status.OverriddenBy = p.bindsAfterDMS
|
||||
status.StatusMessage = "Some DMS binds may be overridden by config binds"
|
||||
default:
|
||||
status.Effective = true
|
||||
status.StatusMessage = "DMS binds are active"
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
|
||||
parts := make([]string, 0, len(kb.Mods)+1)
|
||||
parts = append(parts, kb.Mods...)
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) normalizeKey(key string) string {
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
|
||||
key := p.formatBindKey(kb)
|
||||
normalizedKey := p.normalizeKey(key)
|
||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
|
||||
|
||||
if isDMSBind {
|
||||
p.dmsBindKeys[normalizedKey] = true
|
||||
} else if p.dmsBindKeys[normalizedKey] {
|
||||
p.bindsAfterDMS++
|
||||
p.conflictingConfigs[normalizedKey] = kb
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
return false
|
||||
} else {
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
}
|
||||
|
||||
if _, exists := p.bindMap[normalizedKey]; !exists {
|
||||
p.bindOrder = append(p.bindOrder, key)
|
||||
}
|
||||
p.bindMap[normalizedKey] = kb
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
|
||||
expandedDir, err := utils.ExpandPath(p.configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
|
||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||
p.dmsBindsExists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
|
||||
section, err := p.parseFileWithSource(mainConfig, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.dmsBindsExists && !p.dmsProcessed {
|
||||
p.parseDMSBindsDirectly(dmsBindsPath, section)
|
||||
}
|
||||
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.processedFiles[absPath] {
|
||||
return &HyprlandSection{Name: sectionName}, nil
|
||||
}
|
||||
p.processedFiles[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
section := &HyprlandSection{Name: sectionName}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "source") {
|
||||
p.handleSource(trimmed, section, filepath.Dir(absPath))
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.parseBindLine(line)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
kb.Source = p.currentSource
|
||||
if p.addBind(kb) {
|
||||
section.Keybinds = append(section.Keybinds, *kb)
|
||||
}
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
|
||||
|
||||
p.includeCount++
|
||||
if isDMSSource {
|
||||
p.dmsBindsIncluded = true
|
||||
p.dmsIncludePos = p.includeCount
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
includedSection, err := p.parseFileWithSource(expanded, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
section.Children = append(section.Children, *includedSection)
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
|
||||
data, err := os.ReadFile(dmsBindsPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = dmsBindsPath
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.parseBindLine(line)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
kb.Source = dmsBindsPath
|
||||
if p.addBind(kb) {
|
||||
section.Keybinds = append(section.Keybinds, *kb)
|
||||
}
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract bind type and flags from the left side of "="
|
||||
bindType := strings.TrimSpace(parts[0])
|
||||
flags := extractBindFlags(bindType)
|
||||
hasDescFlag := strings.Contains(flags, "d")
|
||||
|
||||
keys := parts[1]
|
||||
keyParts := strings.SplitN(keys, "#", 2)
|
||||
keys = keyParts[0]
|
||||
|
||||
var comment string
|
||||
if len(keyParts) > 1 {
|
||||
comment = strings.TrimSpace(keyParts[1])
|
||||
}
|
||||
|
||||
// For bindd, the format is: bindd = MODS, key, description, dispatcher, params
|
||||
// For regular binds: bind = MODS, key, dispatcher, params
|
||||
var minFields, descIndex, dispatcherIndex int
|
||||
if hasDescFlag {
|
||||
minFields = 4 // mods, key, description, dispatcher
|
||||
descIndex = 2
|
||||
dispatcherIndex = 3
|
||||
} else {
|
||||
minFields = 3 // mods, key, dispatcher
|
||||
dispatcherIndex = 2
|
||||
}
|
||||
|
||||
keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params
|
||||
if len(keyFields) < minFields {
|
||||
return nil
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(keyFields[0])
|
||||
key := strings.TrimSpace(keyFields[1])
|
||||
|
||||
var dispatcher, params string
|
||||
if hasDescFlag {
|
||||
// bindd format: description is in the bind itself
|
||||
if comment == "" {
|
||||
comment = strings.TrimSpace(keyFields[descIndex])
|
||||
}
|
||||
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
|
||||
if len(keyFields) > dispatcherIndex+1 {
|
||||
paramParts := keyFields[dispatcherIndex+1:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
} else {
|
||||
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
|
||||
if len(keyFields) > dispatcherIndex+1 {
|
||||
paramParts := keyFields[dispatcherIndex+1:]
|
||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
||||
}
|
||||
}
|
||||
|
||||
if comment != "" && strings.HasPrefix(comment, HideComment) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if comment == "" {
|
||||
comment = hyprlandAutogenerateComment(dispatcher, params)
|
||||
}
|
||||
|
||||
var modList []string
|
||||
if mods != "" {
|
||||
modstring := mods + string(ModSeparators[0])
|
||||
idx := 0
|
||||
for index, char := range modstring {
|
||||
isModSep := false
|
||||
for _, sep := range ModSeparators {
|
||||
if char == sep {
|
||||
isModSep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isModSep {
|
||||
if index-idx > 1 {
|
||||
modList = append(modList, modstring[idx:index])
|
||||
}
|
||||
idx = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &HyprlandKeyBinding{
|
||||
Mods: modList,
|
||||
Key: key,
|
||||
Dispatcher: dispatcher,
|
||||
Params: params,
|
||||
Comment: comment,
|
||||
Flags: flags,
|
||||
}
|
||||
}
|
||||
|
||||
// extractBindFlags extracts the flags from a bind type string
|
||||
// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d"
|
||||
func extractBindFlags(bindType string) string {
|
||||
bindType = strings.TrimSpace(bindType)
|
||||
if !strings.HasPrefix(bindType, "bind") {
|
||||
return ""
|
||||
}
|
||||
return bindType[4:] // Everything after "bind"
|
||||
}
|
||||
|
||||
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
||||
parser := NewHyprlandParser(path)
|
||||
section, err := parser.ParseWithDMS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &HyprlandParseResult{
|
||||
Section: section,
|
||||
DMSBindsIncluded: parser.dmsBindsIncluded,
|
||||
DMSStatus: parser.buildDMSStatus(),
|
||||
ConflictingConfigs: parser.conflictingConfigs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
parser.contentLines = []string{tt.line}
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
@@ -285,7 +285,7 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) {
|
||||
t.Fatalf("Failed to write file2: %v", err)
|
||||
}
|
||||
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
if err := parser.ReadContent(tmpDir); err != nil {
|
||||
t.Fatalf("ReadContent failed: %v", err)
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
|
||||
t.Skip("Cannot create relative path")
|
||||
}
|
||||
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
tildePathMatch := "~/" + relPath
|
||||
err = parser.ReadContent(tildePathMatch)
|
||||
|
||||
@@ -353,7 +353,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) {
|
||||
parser := NewHyprlandParser()
|
||||
parser := NewHyprlandParser("")
|
||||
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
|
||||
|
||||
result := parser.getKeybindAtLine(0)
|
||||
@@ -394,3 +394,126 @@ bind = SUPER, T, exec, kitty
|
||||
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBindFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
bindType string
|
||||
expected string
|
||||
}{
|
||||
{"bind", ""},
|
||||
{"binde", "e"},
|
||||
{"bindl", "l"},
|
||||
{"bindr", "r"},
|
||||
{"bindd", "d"},
|
||||
{"bindo", "o"},
|
||||
{"bindel", "el"},
|
||||
{"bindler", "ler"},
|
||||
{"bindem", "em"},
|
||||
{" bind ", ""},
|
||||
{" binde ", "e"},
|
||||
{"notbind", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.bindType, func(t *testing.T) {
|
||||
result := extractBindFlags(tt.bindType)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractBindFlags(%q) = %q, want %q", tt.bindType, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandBindFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
expectedFlags string
|
||||
expectedKey string
|
||||
expectedDisp string
|
||||
expectedDesc string
|
||||
}{
|
||||
{
|
||||
name: "regular bind",
|
||||
line: "bind = SUPER, Q, killactive",
|
||||
expectedFlags: "",
|
||||
expectedKey: "Q",
|
||||
expectedDisp: "killactive",
|
||||
expectedDesc: "Close window",
|
||||
},
|
||||
{
|
||||
name: "binde (repeat on hold)",
|
||||
line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
|
||||
expectedFlags: "e",
|
||||
expectedKey: "XF86AudioRaiseVolume",
|
||||
expectedDisp: "exec",
|
||||
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
|
||||
},
|
||||
{
|
||||
name: "bindl (locked/inhibitor bypass)",
|
||||
line: "bindl = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
|
||||
expectedFlags: "l",
|
||||
expectedKey: "XF86AudioLowerVolume",
|
||||
expectedDisp: "exec",
|
||||
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
|
||||
},
|
||||
{
|
||||
name: "bindr (release trigger)",
|
||||
line: "bindr = SUPER, SUPER_L, exec, pkill wofi || wofi",
|
||||
expectedFlags: "r",
|
||||
expectedKey: "SUPER_L",
|
||||
expectedDisp: "exec",
|
||||
expectedDesc: "pkill wofi || wofi",
|
||||
},
|
||||
{
|
||||
name: "bindd (description)",
|
||||
line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty",
|
||||
expectedFlags: "d",
|
||||
expectedKey: "Q",
|
||||
expectedDisp: "exec",
|
||||
expectedDesc: "Open my favourite terminal",
|
||||
},
|
||||
{
|
||||
name: "bindo (long press)",
|
||||
line: "bindo = SUPER, XF86AudioNext, exec, playerctl next",
|
||||
expectedFlags: "o",
|
||||
expectedKey: "XF86AudioNext",
|
||||
expectedDisp: "exec",
|
||||
expectedDesc: "playerctl next",
|
||||
},
|
||||
{
|
||||
name: "bindel (combined flags)",
|
||||
line: "bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
|
||||
expectedFlags: "el",
|
||||
expectedKey: "XF86AudioRaiseVolume",
|
||||
expectedDisp: "exec",
|
||||
expectedDesc: "wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewHyprlandParser("")
|
||||
parser.contentLines = []string{tt.line}
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected keybind, got nil")
|
||||
}
|
||||
|
||||
if result.Flags != tt.expectedFlags {
|
||||
t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags)
|
||||
}
|
||||
if result.Key != tt.expectedKey {
|
||||
t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey)
|
||||
}
|
||||
if result.Dispatcher != tt.expectedDisp {
|
||||
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp)
|
||||
}
|
||||
if result.Comment != tt.expectedDesc {
|
||||
t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,35 +7,30 @@ import (
|
||||
)
|
||||
|
||||
func TestNewHyprlandProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "custom path",
|
||||
configPath: "/custom/path",
|
||||
wantPath: "/custom/path",
|
||||
},
|
||||
{
|
||||
name: "empty path defaults",
|
||||
configPath: "",
|
||||
wantPath: "$HOME/.config/hypr",
|
||||
},
|
||||
}
|
||||
t.Run("custom path", func(t *testing.T) {
|
||||
p := NewHyprlandProvider("/custom/path")
|
||||
if p == nil {
|
||||
t.Fatal("NewHyprlandProvider returned nil")
|
||||
}
|
||||
if p.configPath != "/custom/path" {
|
||||
t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := NewHyprlandProvider(tt.configPath)
|
||||
if p == nil {
|
||||
t.Fatal("NewHyprlandProvider returned nil")
|
||||
}
|
||||
|
||||
if p.configPath != tt.wantPath {
|
||||
t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("empty path defaults", func(t *testing.T) {
|
||||
p := NewHyprlandProvider("")
|
||||
if p == nil {
|
||||
t.Fatal("NewHyprlandProvider returned nil")
|
||||
}
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir failed: %v", err)
|
||||
}
|
||||
expected := filepath.Join(configDir, "hypr")
|
||||
if p.configPath != expected {
|
||||
t.Errorf("configPath = %q, want %q", p.configPath, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHyprlandProviderName(t *testing.T) {
|
||||
@@ -109,7 +104,7 @@ func TestHyprlandProviderGetCheatSheetError(t *testing.T) {
|
||||
|
||||
func TestFormatKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "test.conf")
|
||||
configFile := filepath.Join(tmpDir, "hyprland.conf")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -163,7 +158,7 @@ func TestFormatKey(t *testing.T) {
|
||||
|
||||
func TestDescriptionFallback(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "test.conf")
|
||||
configFile := filepath.Join(tmpDir, "hyprland.conf")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -2,46 +2,94 @@ package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type MangoWCProvider struct {
|
||||
configPath string
|
||||
configPath string
|
||||
dmsBindsIncluded bool
|
||||
parsed bool
|
||||
}
|
||||
|
||||
func NewMangoWCProvider(configPath string) *MangoWCProvider {
|
||||
if configPath == "" {
|
||||
configPath = "$HOME/.config/mango"
|
||||
configPath = defaultMangoWCConfigDir()
|
||||
}
|
||||
return &MangoWCProvider{
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultMangoWCConfigDir() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "mango")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) Name() string {
|
||||
return "mangowc"
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
keybinds_list, err := ParseMangoWCKeys(m.configPath)
|
||||
result, err := ParseMangoWCKeysWithDMS(m.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse mangowc config: %w", err)
|
||||
}
|
||||
|
||||
m.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
m.parsed = true
|
||||
|
||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||
for _, kb := range keybinds_list {
|
||||
for _, kb := range result.Keybinds {
|
||||
category := m.categorizeByCommand(kb.Command)
|
||||
bind := m.convertKeybind(&kb)
|
||||
bind := m.convertKeybind(&kb, result.ConflictingConfigs)
|
||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||
}
|
||||
|
||||
return &keybinds.CheatSheet{
|
||||
Title: "MangoWC Keybinds",
|
||||
Provider: m.Name(),
|
||||
Binds: categorizedBinds,
|
||||
}, nil
|
||||
sheet := &keybinds.CheatSheet{
|
||||
Title: "MangoWC Keybinds",
|
||||
Provider: m.Name(),
|
||||
Binds: categorizedBinds,
|
||||
DMSBindsIncluded: result.DMSBindsIncluded,
|
||||
}
|
||||
|
||||
if result.DMSStatus != nil {
|
||||
sheet.DMSStatus = &keybinds.DMSBindsStatus{
|
||||
Exists: result.DMSStatus.Exists,
|
||||
Included: result.DMSStatus.Included,
|
||||
IncludePosition: result.DMSStatus.IncludePosition,
|
||||
TotalIncludes: result.DMSStatus.TotalIncludes,
|
||||
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
|
||||
Effective: result.DMSStatus.Effective,
|
||||
OverriddenBy: result.DMSStatus.OverriddenBy,
|
||||
StatusMessage: result.DMSStatus.StatusMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) HasDMSBindsIncluded() bool {
|
||||
if m.parsed {
|
||||
return m.dmsBindsIncluded
|
||||
}
|
||||
|
||||
result, err := ParseMangoWCKeysWithDMS(m.configPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
m.dmsBindsIncluded = result.DMSBindsIncluded
|
||||
m.parsed = true
|
||||
return m.dmsBindsIncluded
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) categorizeByCommand(command string) string {
|
||||
@@ -82,8 +130,8 @@ func (m *MangoWCProvider) categorizeByCommand(command string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind {
|
||||
key := m.formatKey(kb)
|
||||
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind {
|
||||
keyStr := m.formatKey(kb)
|
||||
rawAction := m.formatRawAction(kb.Command, kb.Params)
|
||||
desc := kb.Comment
|
||||
|
||||
@@ -91,11 +139,31 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind
|
||||
desc = rawAction
|
||||
}
|
||||
|
||||
return keybinds.Keybind{
|
||||
Key: key,
|
||||
source := "config"
|
||||
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
|
||||
source = "dms"
|
||||
}
|
||||
|
||||
bind := keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: desc,
|
||||
Action: rawAction,
|
||||
Source: source,
|
||||
}
|
||||
|
||||
if source == "dms" && conflicts != nil {
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||
bind.Conflict = &keybinds.Keybind{
|
||||
Key: keyStr,
|
||||
Description: conflictKb.Comment,
|
||||
Action: m.formatRawAction(conflictKb.Command, conflictKb.Params),
|
||||
Source: "config",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bind
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) formatRawAction(command, params string) string {
|
||||
@@ -111,3 +179,264 @@ func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string {
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) GetOverridePath() string {
|
||||
expanded, err := utils.ExpandPath(m.configPath)
|
||||
if err != nil {
|
||||
return filepath.Join(m.configPath, "dms", "binds.conf")
|
||||
}
|
||||
return filepath.Join(expanded, "dms", "binds.conf")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) validateAction(action string) error {
|
||||
action = strings.TrimSpace(action)
|
||||
switch {
|
||||
case action == "":
|
||||
return fmt.Errorf("action cannot be empty")
|
||||
case action == "spawn" || action == "spawn ":
|
||||
return fmt.Errorf("spawn command requires arguments")
|
||||
case action == "spawn_shell" || action == "spawn_shell ":
|
||||
return fmt.Errorf("spawn_shell command requires arguments")
|
||||
case strings.HasPrefix(action, "spawn "):
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn "))
|
||||
if rest == "" {
|
||||
return fmt.Errorf("spawn command requires arguments")
|
||||
}
|
||||
case strings.HasPrefix(action, "spawn_shell "):
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell "))
|
||||
if rest == "" {
|
||||
return fmt.Errorf("spawn_shell command requires arguments")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error {
|
||||
if err := m.validateAction(action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
overridePath := m.GetOverridePath()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dms directory: %w", err)
|
||||
}
|
||||
|
||||
existingBinds, err := m.loadOverrideBinds()
|
||||
if err != nil {
|
||||
existingBinds = make(map[string]*mangowcOverrideBind)
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
existingBinds[normalizedKey] = &mangowcOverrideBind{
|
||||
Key: key,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Options: options,
|
||||
}
|
||||
|
||||
return m.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) RemoveBind(key string) error {
|
||||
existingBinds, err := m.loadOverrideBinds()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
delete(existingBinds, normalizedKey)
|
||||
return m.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
|
||||
type mangowcOverrideBind struct {
|
||||
Key string
|
||||
Action string
|
||||
Description string
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
|
||||
overridePath := m.GetOverridePath()
|
||||
binds := make(map[string]*mangowcOverrideBind)
|
||||
|
||||
data, err := os.ReadFile(overridePath)
|
||||
if os.IsNotExist(err) {
|
||||
return binds, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(line, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(parts[1])
|
||||
commentParts := strings.SplitN(content, "#", 2)
|
||||
bindContent := strings.TrimSpace(commentParts[0])
|
||||
|
||||
var comment string
|
||||
if len(commentParts) > 1 {
|
||||
comment = strings.TrimSpace(commentParts[1])
|
||||
}
|
||||
|
||||
fields := strings.SplitN(bindContent, ",", 4)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(fields[0])
|
||||
keyName := strings.TrimSpace(fields[1])
|
||||
command := strings.TrimSpace(fields[2])
|
||||
|
||||
var params string
|
||||
if len(fields) > 3 {
|
||||
params = strings.TrimSpace(fields[3])
|
||||
}
|
||||
|
||||
keyStr := m.buildKeyString(mods, keyName)
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
action := command
|
||||
if params != "" {
|
||||
action = command + " " + params
|
||||
}
|
||||
|
||||
binds[normalizedKey] = &mangowcOverrideBind{
|
||||
Key: keyStr,
|
||||
Action: action,
|
||||
Description: comment,
|
||||
}
|
||||
}
|
||||
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
|
||||
if mods == "" || strings.EqualFold(mods, "none") {
|
||||
return key
|
||||
}
|
||||
|
||||
modList := strings.FieldsFunc(mods, func(r rune) bool {
|
||||
return r == '+' || r == ' '
|
||||
})
|
||||
|
||||
parts := append(modList, key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) getBindSortPriority(action string) int {
|
||||
switch {
|
||||
case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"):
|
||||
return 0
|
||||
case strings.Contains(action, "view") || strings.Contains(action, "tag"):
|
||||
return 1
|
||||
case strings.Contains(action, "focus") || strings.Contains(action, "exchange") ||
|
||||
strings.Contains(action, "resize") || strings.Contains(action, "move"):
|
||||
return 2
|
||||
case strings.Contains(action, "mon"):
|
||||
return 3
|
||||
case strings.HasPrefix(action, "spawn"):
|
||||
return 4
|
||||
case action == "quit" || action == "reload_config":
|
||||
return 5
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
|
||||
overridePath := m.GetOverridePath()
|
||||
content := m.generateBindsContent(binds)
|
||||
return os.WriteFile(overridePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
|
||||
if len(binds) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
bindList := make([]*mangowcOverrideBind, 0, len(binds))
|
||||
for _, bind := range binds {
|
||||
bindList = append(bindList, bind)
|
||||
}
|
||||
|
||||
sort.Slice(bindList, func(i, j int) bool {
|
||||
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return bindList[i].Key < bindList[j].Key
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
for _, bind := range bindList {
|
||||
m.writeBindLine(&sb, bind)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
|
||||
mods, key := m.parseKeyString(bind.Key)
|
||||
command, params := m.parseAction(bind.Action)
|
||||
|
||||
sb.WriteString("bind=")
|
||||
if mods == "" {
|
||||
sb.WriteString("none")
|
||||
} else {
|
||||
sb.WriteString(mods)
|
||||
}
|
||||
sb.WriteString(",")
|
||||
sb.WriteString(key)
|
||||
sb.WriteString(",")
|
||||
sb.WriteString(command)
|
||||
|
||||
if params != "" {
|
||||
sb.WriteString(",")
|
||||
sb.WriteString(params)
|
||||
}
|
||||
|
||||
if bind.Description != "" {
|
||||
sb.WriteString(" # ")
|
||||
sb.WriteString(bind.Description)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
|
||||
parts := strings.Split(keyStr, "+")
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return "", keyStr
|
||||
case 1:
|
||||
return "", parts[0]
|
||||
default:
|
||||
return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) parseAction(action string) (command, params string) {
|
||||
parts := strings.SplitN(action, " ", 2)
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return action, ""
|
||||
case 1:
|
||||
return parts[0], ""
|
||||
default:
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,40 @@ type MangoWCKeyBinding struct {
|
||||
Command string `json:"command"`
|
||||
Params string `json:"params"`
|
||||
Comment string `json:"comment"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type MangoWCParser struct {
|
||||
contentLines []string
|
||||
readingLine int
|
||||
contentLines []string
|
||||
readingLine int
|
||||
configDir string
|
||||
currentSource string
|
||||
dmsBindsExists bool
|
||||
dmsBindsIncluded bool
|
||||
includeCount int
|
||||
dmsIncludePos int
|
||||
bindsAfterDMS int
|
||||
dmsBindKeys map[string]bool
|
||||
configBindKeys map[string]bool
|
||||
conflictingConfigs map[string]*MangoWCKeyBinding
|
||||
bindMap map[string]*MangoWCKeyBinding
|
||||
bindOrder []string
|
||||
processedFiles map[string]bool
|
||||
dmsProcessed bool
|
||||
}
|
||||
|
||||
func NewMangoWCParser() *MangoWCParser {
|
||||
func NewMangoWCParser(configDir string) *MangoWCParser {
|
||||
return &MangoWCParser{
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
contentLines: []string{},
|
||||
readingLine: 0,
|
||||
configDir: configDir,
|
||||
dmsIncludePos: -1,
|
||||
dmsBindKeys: make(map[string]bool),
|
||||
configBindKeys: make(map[string]bool),
|
||||
conflictingConfigs: make(map[string]*MangoWCKeyBinding),
|
||||
bindMap: make(map[string]*MangoWCKeyBinding),
|
||||
bindOrder: []string{},
|
||||
processedFiles: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,9 +317,320 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
|
||||
}
|
||||
|
||||
func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) {
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser(path)
|
||||
if err := parser.ReadContent(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parser.ParseKeys(), nil
|
||||
}
|
||||
|
||||
type MangoWCParseResult struct {
|
||||
Keybinds []MangoWCKeyBinding
|
||||
DMSBindsIncluded bool
|
||||
DMSStatus *MangoWCDMSStatus
|
||||
ConflictingConfigs map[string]*MangoWCKeyBinding
|
||||
}
|
||||
|
||||
type MangoWCDMSStatus struct {
|
||||
Exists bool
|
||||
Included bool
|
||||
IncludePosition int
|
||||
TotalIncludes int
|
||||
BindsAfterDMS int
|
||||
Effective bool
|
||||
OverriddenBy int
|
||||
StatusMessage string
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus {
|
||||
status := &MangoWCDMSStatus{
|
||||
Exists: p.dmsBindsExists,
|
||||
Included: p.dmsBindsIncluded,
|
||||
IncludePosition: p.dmsIncludePos,
|
||||
TotalIncludes: p.includeCount,
|
||||
BindsAfterDMS: p.bindsAfterDMS,
|
||||
}
|
||||
|
||||
switch {
|
||||
case !p.dmsBindsExists:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf does not exist"
|
||||
case !p.dmsBindsIncluded:
|
||||
status.Effective = false
|
||||
status.StatusMessage = "dms/binds.conf is not sourced in config"
|
||||
case p.bindsAfterDMS > 0:
|
||||
status.Effective = true
|
||||
status.OverriddenBy = p.bindsAfterDMS
|
||||
status.StatusMessage = "Some DMS binds may be overridden by config binds"
|
||||
default:
|
||||
status.Effective = true
|
||||
status.StatusMessage = "DMS binds are active"
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string {
|
||||
parts := make([]string, 0, len(kb.Mods)+1)
|
||||
parts = append(parts, kb.Mods...)
|
||||
parts = append(parts, kb.Key)
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) normalizeKey(key string) string {
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) {
|
||||
key := p.formatBindKey(kb)
|
||||
normalizedKey := p.normalizeKey(key)
|
||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf")
|
||||
|
||||
if isDMSBind {
|
||||
p.dmsBindKeys[normalizedKey] = true
|
||||
} else if p.dmsBindKeys[normalizedKey] {
|
||||
p.bindsAfterDMS++
|
||||
p.conflictingConfigs[normalizedKey] = kb
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
return
|
||||
} else {
|
||||
p.configBindKeys[normalizedKey] = true
|
||||
}
|
||||
|
||||
if _, exists := p.bindMap[normalizedKey]; !exists {
|
||||
p.bindOrder = append(p.bindOrder, key)
|
||||
}
|
||||
p.bindMap[normalizedKey] = kb
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) {
|
||||
expandedDir, err := utils.ExpandPath(p.configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
|
||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||
p.dmsBindsExists = true
|
||||
}
|
||||
|
||||
mainConfig := filepath.Join(expandedDir, "config.conf")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
mainConfig = filepath.Join(expandedDir, "mango.conf")
|
||||
}
|
||||
|
||||
_, err = p.parseFileWithSource(mainConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.dmsBindsExists && !p.dmsProcessed {
|
||||
p.parseDMSBindsDirectly(dmsBindsPath)
|
||||
}
|
||||
|
||||
var keybinds []MangoWCKeyBinding
|
||||
for _, key := range p.bindOrder {
|
||||
normalizedKey := p.normalizeKey(key)
|
||||
if kb, exists := p.bindMap[normalizedKey]; exists {
|
||||
keybinds = append(keybinds, *kb)
|
||||
}
|
||||
}
|
||||
|
||||
return keybinds, nil
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.processedFiles[absPath] {
|
||||
return nil, nil
|
||||
}
|
||||
p.processedFiles[absPath] = true
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
var keybinds []MangoWCKeyBinding
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for lineNum, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "source") {
|
||||
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.getKeybindAtLineContent(line, lineNum)
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
kb.Source = p.currentSource
|
||||
p.addBind(kb)
|
||||
keybinds = append(keybinds, *kb)
|
||||
}
|
||||
|
||||
p.currentSource = prevSource
|
||||
return keybinds, nil
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath := strings.TrimSpace(parts[1])
|
||||
isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
|
||||
|
||||
p.includeCount++
|
||||
if isDMSSource {
|
||||
p.dmsBindsIncluded = true
|
||||
p.dmsIncludePos = p.includeCount
|
||||
p.dmsProcessed = true
|
||||
}
|
||||
|
||||
fullPath := sourcePath
|
||||
if !filepath.IsAbs(sourcePath) {
|
||||
fullPath = filepath.Join(baseDir, sourcePath)
|
||||
}
|
||||
|
||||
expanded, err := utils.ExpandPath(fullPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
includedBinds, err := p.parseFileWithSource(expanded)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
*keybinds = append(*keybinds, includedBinds...)
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding {
|
||||
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
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
|
||||
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
|
||||
matches := bindMatch.FindStringSubmatch(line)
|
||||
if len(matches) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := matches[2]
|
||||
parts := strings.SplitN(content, "#", 2)
|
||||
keys := parts[0]
|
||||
|
||||
var comment string
|
||||
if len(parts) > 1 {
|
||||
comment = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
if strings.HasPrefix(comment, MangoWCHideComment) {
|
||||
return nil
|
||||
}
|
||||
|
||||
keyFields := strings.SplitN(keys, ",", 4)
|
||||
if len(keyFields) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(keyFields[0])
|
||||
key := strings.TrimSpace(keyFields[1])
|
||||
command := strings.TrimSpace(keyFields[2])
|
||||
|
||||
var params string
|
||||
if len(keyFields) > 3 {
|
||||
params = strings.TrimSpace(keyFields[3])
|
||||
}
|
||||
|
||||
if comment == "" {
|
||||
comment = mangowcAutogenerateComment(command, params)
|
||||
}
|
||||
|
||||
var modList []string
|
||||
if mods != "" && !strings.EqualFold(mods, "none") {
|
||||
modstring := mods + string(MangoWCModSeparators[0])
|
||||
idx := 0
|
||||
for index, char := range modstring {
|
||||
isModSep := false
|
||||
for _, sep := range MangoWCModSeparators {
|
||||
if char == sep {
|
||||
isModSep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isModSep {
|
||||
if index-idx > 1 {
|
||||
modList = append(modList, modstring[idx:index])
|
||||
}
|
||||
idx = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &MangoWCKeyBinding{
|
||||
Mods: modList,
|
||||
Key: key,
|
||||
Command: command,
|
||||
Params: params,
|
||||
Comment: comment,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) {
|
||||
parser := NewMangoWCParser(path)
|
||||
keybinds, err := parser.ParseWithDMS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MangoWCParseResult{
|
||||
Keybinds: keybinds,
|
||||
DMSBindsIncluded: parser.dmsBindsIncluded,
|
||||
DMSStatus: parser.buildDMSStatus(),
|
||||
ConflictingConfigs: parser.conflictingConfigs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
parser.contentLines = []string{tt.line}
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
@@ -283,7 +283,7 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) {
|
||||
t.Fatalf("Failed to write file2: %v", err)
|
||||
}
|
||||
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
if err := parser.ReadContent(tmpDir); err != nil {
|
||||
t.Fatalf("ReadContent failed: %v", err)
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func TestMangoWCReadContentSingleFile(t *testing.T) {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
if err := parser.ReadContent(configFile); err != nil {
|
||||
t.Fatalf("ReadContent failed: %v", err)
|
||||
}
|
||||
@@ -362,7 +362,7 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
|
||||
t.Skip("Cannot create relative path")
|
||||
}
|
||||
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
tildePathMatch := "~/" + relPath
|
||||
err = parser.ReadContent(tildePathMatch)
|
||||
|
||||
@@ -419,7 +419,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := NewMangoWCParser()
|
||||
parser := NewMangoWCParser("")
|
||||
parser.contentLines = []string{tt.line}
|
||||
result := parser.getKeybindAtLine(0)
|
||||
|
||||
|
||||
@@ -15,8 +15,17 @@ func TestMangoWCProviderName(t *testing.T) {
|
||||
|
||||
func TestMangoWCProviderDefaultPath(t *testing.T) {
|
||||
provider := NewMangoWCProvider("")
|
||||
if provider.configPath != "$HOME/.config/mango" {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango")
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
// Fall back to testing for non-empty path
|
||||
if provider.configPath == "" {
|
||||
t.Error("configPath should not be empty")
|
||||
}
|
||||
return
|
||||
}
|
||||
expected := filepath.Join(configDir, "mango")
|
||||
if provider.configPath != expected {
|
||||
t.Errorf("configPath = %q, want %q", provider.configPath, expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +183,7 @@ func TestMangoWCConvertKeybind(t *testing.T) {
|
||||
provider := NewMangoWCProvider("")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := provider.convertKeybind(tt.keybind)
|
||||
result := provider.convertKeybind(tt.keybind, nil)
|
||||
if result.Key != tt.wantKey {
|
||||
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,15 @@ func (n *NiriProvider) formatRawAction(action string, args []string) string {
|
||||
}
|
||||
}
|
||||
|
||||
return action + " " + strings.Join(args, " ")
|
||||
quotedArgs := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
if arg == "" {
|
||||
quotedArgs[i] = `""`
|
||||
} else {
|
||||
quotedArgs[i] = arg
|
||||
}
|
||||
}
|
||||
return action + " " + strings.Join(quotedArgs, " ")
|
||||
}
|
||||
|
||||
func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string {
|
||||
@@ -293,9 +301,15 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
|
||||
continue
|
||||
}
|
||||
keyStr := parser.formatBindKey(kb)
|
||||
|
||||
action := n.buildActionFromNode(child)
|
||||
if action == "" {
|
||||
action = n.formatRawAction(kb.Action, kb.Args)
|
||||
}
|
||||
|
||||
binds[keyStr] = &overrideBind{
|
||||
Key: keyStr,
|
||||
Action: n.formatRawAction(kb.Action, kb.Args),
|
||||
Action: action,
|
||||
Description: kb.Description,
|
||||
Options: n.extractOptions(child),
|
||||
}
|
||||
@@ -305,6 +319,42 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
|
||||
if len(bindNode.Children) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
actionNode := bindNode.Children[0]
|
||||
actionName := actionNode.Name.String()
|
||||
if actionName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{actionName}
|
||||
for _, arg := range actionNode.Arguments {
|
||||
val := arg.ValueString()
|
||||
if val == "" {
|
||||
parts = append(parts, `""`)
|
||||
} else {
|
||||
parts = append(parts, val)
|
||||
}
|
||||
}
|
||||
|
||||
if actionNode.Properties != nil {
|
||||
if val, ok := actionNode.Properties.Get("focus"); ok {
|
||||
parts = append(parts, "focus="+val.String())
|
||||
}
|
||||
if val, ok := actionNode.Properties.Get("show-pointer"); ok {
|
||||
parts = append(parts, "show-pointer="+val.String())
|
||||
}
|
||||
if val, ok := actionNode.Properties.Get("write-to-disk"); ok {
|
||||
parts = append(parts, "write-to-disk="+val.String())
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
|
||||
if node.Properties == nil {
|
||||
return make(map[string]any)
|
||||
@@ -461,16 +511,9 @@ func (n *NiriProvider) getBindSortPriority(action string) int {
|
||||
}
|
||||
}
|
||||
|
||||
const dmsWarningHeader = `// ! DO NOT EDIT !
|
||||
// ! AUTO-GENERATED BY DMS !
|
||||
// ! CHANGES WILL BE OVERWRITTEN !
|
||||
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
|
||||
|
||||
`
|
||||
|
||||
func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string {
|
||||
if len(binds) == 0 {
|
||||
return dmsWarningHeader + "binds {}\n"
|
||||
return "binds {}\n"
|
||||
}
|
||||
|
||||
var regularBinds, recentWindowsBinds []*overrideBind
|
||||
@@ -497,7 +540,6 @@ func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) stri
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(dmsWarningHeader)
|
||||
sb.WriteString("binds {\n")
|
||||
for _, bind := range regularBinds {
|
||||
n.writeBindNode(&sb, bind, " ")
|
||||
|
||||
@@ -6,13 +6,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testHeader = `// ! DO NOT EDIT !
|
||||
// ! AUTO-GENERATED BY DMS !
|
||||
// ! CHANGES WILL BE OVERWRITTEN !
|
||||
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
|
||||
|
||||
`
|
||||
|
||||
func TestNiriProviderName(t *testing.T) {
|
||||
provider := NewNiriProvider("")
|
||||
if provider.Name() != "niri" {
|
||||
@@ -128,6 +121,8 @@ func TestNiriFormatRawAction(t *testing.T) {
|
||||
}{
|
||||
{"spawn", []string{"kitty"}, "spawn kitty"},
|
||||
{"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"},
|
||||
{"spawn", []string{"dms", "ipc", "call", "brightness", "increment", "5", ""}, `spawn dms ipc call brightness increment 5 ""`},
|
||||
{"spawn", []string{"dms", "ipc", "call", "dash", "toggle", ""}, `spawn dms ipc call dash toggle ""`},
|
||||
{"close-window", nil, "close-window"},
|
||||
{"fullscreen-window", nil, "fullscreen-window"},
|
||||
{"focus-workspace", []string{"1"}, "focus-workspace 1"},
|
||||
@@ -204,7 +199,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
{
|
||||
name: "empty binds",
|
||||
binds: map[string]*overrideBind{},
|
||||
expected: testHeader + "binds {}\n",
|
||||
expected: "binds {}\n",
|
||||
},
|
||||
{
|
||||
name: "simple spawn bind",
|
||||
@@ -215,7 +210,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
Description: "Open Terminal",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
|
||||
}
|
||||
`,
|
||||
@@ -229,7 +224,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
Description: "Application Launcher",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
||||
}
|
||||
`,
|
||||
@@ -243,7 +238,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
Options: map[string]any{"allow-when-locked": true},
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; }
|
||||
}
|
||||
`,
|
||||
@@ -257,7 +252,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
Description: "Close Window",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
Mod+Q hotkey-overlay-title="Close Window" { close-window; }
|
||||
}
|
||||
`,
|
||||
@@ -270,7 +265,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
Action: "next-window",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
}
|
||||
|
||||
recent-windows {
|
||||
@@ -331,6 +326,58 @@ func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriEmptyArgsPreservation(t *testing.T) {
|
||||
provider := NewNiriProvider("")
|
||||
|
||||
binds := map[string]*overrideBind{
|
||||
"XF86MonBrightnessUp": {
|
||||
Key: "XF86MonBrightnessUp",
|
||||
Action: `spawn dms ipc call brightness increment 5 ""`,
|
||||
Description: "Brightness Up",
|
||||
},
|
||||
"XF86MonBrightnessDown": {
|
||||
Key: "XF86MonBrightnessDown",
|
||||
Action: `spawn dms ipc call brightness decrement 5 ""`,
|
||||
Description: "Brightness Down",
|
||||
},
|
||||
"Super+Alt+Page_Up": {
|
||||
Key: "Super+Alt+Page_Up",
|
||||
Action: `spawn dms ipc call dash toggle ""`,
|
||||
Description: "Dashboard Toggle",
|
||||
},
|
||||
}
|
||||
|
||||
content := provider.generateBindsContent(binds)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("Failed to write binds file: %v", err)
|
||||
}
|
||||
|
||||
testProvider := NewNiriProvider(tmpDir)
|
||||
loadedBinds, err := testProvider.loadOverrideBinds()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load binds: %v\nContent was:\n%s", err, content)
|
||||
}
|
||||
|
||||
for key, expected := range binds {
|
||||
loaded, ok := loadedBinds[key]
|
||||
if !ok {
|
||||
t.Errorf("Missing bind for key %s", key)
|
||||
continue
|
||||
}
|
||||
if loaded.Action != expected.Action {
|
||||
t.Errorf("Action mismatch for %s:\n got: %q\n want: %q", key, loaded.Action, expected.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriProviderWithRealWorldConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||
@@ -422,7 +469,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
|
||||
Description: "Focus Workspace 1",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; }
|
||||
}
|
||||
`,
|
||||
@@ -436,7 +483,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
|
||||
Description: "Focus Workspace 10",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; }
|
||||
}
|
||||
`,
|
||||
@@ -450,7 +497,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
|
||||
Description: "Adjust Column Width -10%",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; }
|
||||
}
|
||||
`,
|
||||
@@ -464,7 +511,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
|
||||
Description: "Adjust Column Width +10%",
|
||||
},
|
||||
},
|
||||
expected: testHeader + `binds {
|
||||
expected: `binds {
|
||||
Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; }
|
||||
}
|
||||
`,
|
||||
@@ -493,7 +540,7 @@ func TestNiriGenerateActionWithUnquotedPercentArg(t *testing.T) {
|
||||
}
|
||||
|
||||
content := provider.generateBindsContent(binds)
|
||||
expected := testHeader + `binds {
|
||||
expected := `binds {
|
||||
Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; }
|
||||
}
|
||||
`
|
||||
@@ -514,7 +561,7 @@ func TestNiriGenerateSpawnWithNumericArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
content := provider.generateBindsContent(binds)
|
||||
expected := testHeader + `binds {
|
||||
expected := `binds {
|
||||
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
|
||||
}
|
||||
`
|
||||
@@ -535,7 +582,7 @@ func TestNiriGenerateSpawnNumericArgFromCLI(t *testing.T) {
|
||||
}
|
||||
|
||||
content := provider.generateBindsContent(binds)
|
||||
expected := testHeader + `binds {
|
||||
expected := `binds {
|
||||
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
|
||||
}
|
||||
`
|
||||
|
||||
@@ -8,6 +8,7 @@ type Keybind struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
|
||||
CooldownMs int `json:"cooldownMs,omitempty"`
|
||||
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
|
||||
Conflict *Keybind `json:"conflict,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,47 @@ const (
|
||||
ColorModeLight ColorMode = "light"
|
||||
)
|
||||
|
||||
type TemplateKind int
|
||||
|
||||
const (
|
||||
TemplateKindNormal TemplateKind = iota
|
||||
TemplateKindTerminal
|
||||
TemplateKindGTK
|
||||
TemplateKindVSCode
|
||||
)
|
||||
|
||||
type TemplateDef struct {
|
||||
ID string
|
||||
Commands []string
|
||||
Flatpaks []string
|
||||
ConfigFile string
|
||||
Kind TemplateKind
|
||||
RunUnconditionally bool
|
||||
}
|
||||
|
||||
var templateRegistry = []TemplateDef{
|
||||
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
|
||||
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
|
||||
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
|
||||
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"},
|
||||
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
|
||||
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
||||
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "alacritty", Commands: []string{"alacritty"}, ConfigFile: "alacritty.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "wezterm", Commands: []string{"wezterm"}, ConfigFile: "wezterm.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "nvim", Commands: []string{"nvim"}, ConfigFile: "neovim.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
|
||||
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
|
||||
{ID: "vscode", Kind: TemplateKindVSCode},
|
||||
}
|
||||
|
||||
func (c *ColorMode) GTKTheme() string {
|
||||
switch *c {
|
||||
case ColorModeDark:
|
||||
@@ -51,6 +92,7 @@ type Options struct {
|
||||
SyncModeWithPortal bool
|
||||
TerminalsAlwaysDark bool
|
||||
SkipTemplates string
|
||||
AppChecker utils.AppChecker
|
||||
}
|
||||
|
||||
type ColorsOutput struct {
|
||||
@@ -101,6 +143,9 @@ func Run(opts Options) error {
|
||||
if opts.IconTheme == "" {
|
||||
opts.IconTheme = "System Default"
|
||||
}
|
||||
if opts.AppChecker == nil {
|
||||
opts.AppChecker = utils.DefaultAppChecker{}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(opts.StateDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create state dir: %w", err)
|
||||
@@ -236,7 +281,7 @@ func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
|
||||
if strings.TrimSpace(line) == "[config]" {
|
||||
continue
|
||||
}
|
||||
cfgFile.WriteString(substituteShellDir(line, opts.ShellDir) + "\n")
|
||||
cfgFile.WriteString(substituteVars(line, opts.ShellDir) + "\n")
|
||||
}
|
||||
cfgFile.WriteString("\n")
|
||||
}
|
||||
@@ -247,73 +292,32 @@ output_path = '%s'
|
||||
|
||||
`, opts.ShellDir, opts.ColorsOutput())
|
||||
|
||||
if !opts.ShouldSkipTemplate("gtk") {
|
||||
switch opts.Mode {
|
||||
case "light":
|
||||
appendConfig(opts, cfgFile, nil, "gtk3-light.toml")
|
||||
default:
|
||||
appendConfig(opts, cfgFile, nil, "gtk3-dark.toml")
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
for _, tmpl := range templateRegistry {
|
||||
if opts.ShouldSkipTemplate(tmpl.ID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("niri") {
|
||||
appendConfig(opts, cfgFile, []string{"niri"}, "niri.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("qt5ct") {
|
||||
appendConfig(opts, cfgFile, []string{"qt5ct"}, "qt5ct.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("qt6ct") {
|
||||
appendConfig(opts, cfgFile, []string{"qt6ct"}, "qt6ct.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("firefox") {
|
||||
appendConfig(opts, cfgFile, []string{"firefox"}, "firefox.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("pywalfox") {
|
||||
appendConfig(opts, cfgFile, []string{"pywalfox"}, "pywalfox.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("zenbrowser") {
|
||||
appendConfig(opts, cfgFile, []string{"zen", "zen-browser"}, "zenbrowser.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("vesktop") {
|
||||
appendConfig(opts, cfgFile, []string{"vesktop"}, "vesktop.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("equibop") {
|
||||
appendConfig(opts, cfgFile, []string{"equibop"}, "equibop.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("ghostty") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"ghostty"}, "ghostty.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("kitty") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"kitty"}, "kitty.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("foot") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"foot"}, "foot.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("alacritty") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"alacritty"}, "alacritty.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("wezterm") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"wezterm"}, "wezterm.toml")
|
||||
}
|
||||
if !opts.ShouldSkipTemplate("nvim") {
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"nvim"}, "neovim.toml")
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("dgop") {
|
||||
appendConfig(opts, cfgFile, []string{"dgop"}, "dgop.toml")
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("kcolorscheme") {
|
||||
appendConfig(opts, cfgFile, nil, "kcolorscheme.toml")
|
||||
}
|
||||
|
||||
if !opts.ShouldSkipTemplate("vscode") {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
|
||||
switch tmpl.Kind {
|
||||
case TemplateKindGTK:
|
||||
switch opts.Mode {
|
||||
case ColorModeLight:
|
||||
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
|
||||
default:
|
||||
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
|
||||
}
|
||||
case TemplateKindTerminal:
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
case TemplateKindVSCode:
|
||||
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
||||
default:
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.RunUserTemplates {
|
||||
@@ -342,28 +346,34 @@ output_path = '%s'
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendConfig(opts *Options, cfgFile *os.File, checkCmd []string, fileName string) {
|
||||
func appendConfig(
|
||||
opts *Options,
|
||||
cfgFile *os.File,
|
||||
checkCmd []string,
|
||||
checkFlatpaks []string,
|
||||
fileName string,
|
||||
) {
|
||||
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
return
|
||||
}
|
||||
if len(checkCmd) > 0 && !utils.AnyCommandExists(checkCmd...) {
|
||||
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cfgFile.WriteString(substituteShellDir(string(data), opts.ShellDir))
|
||||
cfgFile.WriteString(substituteVars(string(data), opts.ShellDir))
|
||||
cfgFile.WriteString("\n")
|
||||
}
|
||||
|
||||
func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkCmd []string, fileName string) {
|
||||
func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkCmd []string, checkFlatpaks []string, fileName string) {
|
||||
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
return
|
||||
}
|
||||
if len(checkCmd) > 0 && !utils.AnyCommandExists(checkCmd...) {
|
||||
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
@@ -374,7 +384,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
|
||||
content := string(data)
|
||||
|
||||
if !opts.TerminalsAlwaysDark {
|
||||
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
|
||||
cfgFile.WriteString(substituteVars(content, opts.ShellDir))
|
||||
cfgFile.WriteString("\n")
|
||||
return
|
||||
}
|
||||
@@ -412,14 +422,32 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
|
||||
fmt.Sprintf("'%s'", tmpPath))
|
||||
}
|
||||
|
||||
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
|
||||
cfgFile.WriteString(substituteVars(content, opts.ShellDir))
|
||||
cfgFile.WriteString("\n")
|
||||
}
|
||||
|
||||
func appendVSCodeConfig(cfgFile *os.File, name, extDir, shellDir string) {
|
||||
if _, err := os.Stat(extDir); err != nil {
|
||||
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
|
||||
// Both nil is treated as "skip check" / unconditionally run
|
||||
if checkCmd == nil && checkFlatpaks == nil {
|
||||
return true
|
||||
}
|
||||
if checkCmd != nil && checker.AnyCommandExists(checkCmd...) {
|
||||
return true
|
||||
}
|
||||
if checkFlatpaks != nil && checker.AnyFlatpakExists(checkFlatpaks...) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
|
||||
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil || len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
extDir := matches[0]
|
||||
templateDir := filepath.Join(shellDir, "matugen", "templates")
|
||||
fmt.Fprintf(cfgFile, `[templates.dms%sdefault]
|
||||
input_path = '%s/vscode-color-theme-default.json'
|
||||
@@ -439,8 +467,12 @@ output_path = '%s/themes/dankshell-light.json'
|
||||
log.Infof("Added %s theme config (extension found at %s)", name, extDir)
|
||||
}
|
||||
|
||||
func substituteShellDir(content, shellDir string) string {
|
||||
return strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
|
||||
func substituteVars(content, shellDir string) string {
|
||||
result := strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
|
||||
result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
|
||||
result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
|
||||
result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
|
||||
return result
|
||||
}
|
||||
|
||||
func extractTOMLSection(content, startMarker, endMarker string) string {
|
||||
@@ -650,3 +682,52 @@ func syncColorScheme(mode ColorMode) {
|
||||
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
|
||||
}
|
||||
}
|
||||
|
||||
type TemplateCheck struct {
|
||||
ID string `json:"id"`
|
||||
Detected bool `json:"detected"`
|
||||
}
|
||||
|
||||
func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
||||
if checker == nil {
|
||||
checker = utils.DefaultAppChecker{}
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
checks := make([]TemplateCheck, 0, len(templateRegistry))
|
||||
|
||||
for _, tmpl := range templateRegistry {
|
||||
detected := false
|
||||
|
||||
switch {
|
||||
case tmpl.RunUnconditionally:
|
||||
detected = true
|
||||
case tmpl.Kind == TemplateKindVSCode:
|
||||
detected = checkVSCodeExtension(homeDir)
|
||||
default:
|
||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
|
||||
}
|
||||
|
||||
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
func checkVSCodeExtension(homeDir string) bool {
|
||||
extDirs := []string{
|
||||
filepath.Join(homeDir, ".vscode/extensions"),
|
||||
filepath.Join(homeDir, ".vscode-oss/extensions"),
|
||||
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
|
||||
filepath.Join(homeDir, ".cursor/extensions"),
|
||||
filepath.Join(homeDir, ".windsurf/extensions"),
|
||||
}
|
||||
|
||||
for _, extDir := range extDirs {
|
||||
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
|
||||
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
394
core/internal/matugen/matugen_test.go
Normal file
394
core/internal/matugen/matugen_test.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package matugen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAppendConfigBinaryExists(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
t.Errorf("expected config to be written when binary exists")
|
||||
}
|
||||
if string(output) != testConfig+"\n" {
|
||||
t.Errorf("expected %q, got %q", testConfig+"\n", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
||||
mockChecker.EXPECT().AnyFlatpakExists().Return(false)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("expected no config when binary doesn't exist, got: %q", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendConfigFlatpakExists(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyFlatpakExists("app.zen_browser.zen").Return(true)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
t.Errorf("expected config to be written when flatpak exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyCommandExists().Return(false)
|
||||
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("expected no config when flatpak doesn't exist, got: %q", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendConfigBothExist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
t.Errorf("expected config to be written when both binary and flatpak exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendConfigNeitherExists(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
||||
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("expected no config when neither exists, got: %q", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendConfigNoChecks(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 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), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
opts := &Options{ShellDir: shellDir}
|
||||
|
||||
appendConfig(opts, cfgFile, nil, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
t.Errorf("expected config to be written when no checks specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendConfigFileDoesNotExist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create configs dir: %v", err)
|
||||
}
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
opts := &Options{ShellDir: shellDir}
|
||||
|
||||
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("expected no config when file doesn't exist, got: %q", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVars(t *testing.T) {
|
||||
configDir := utils.XDGConfigHome()
|
||||
dataDir := utils.XDGDataHome()
|
||||
cacheDir := utils.XDGCacheHome()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
shellDir string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "substitutes SHELL_DIR",
|
||||
input: "input_path = 'SHELL_DIR/matugen/templates/foo.conf'",
|
||||
shellDir: "/home/user/shell",
|
||||
expected: "input_path = '/home/user/shell/matugen/templates/foo.conf'",
|
||||
},
|
||||
{
|
||||
name: "substitutes CONFIG_DIR",
|
||||
input: "output_path = 'CONFIG_DIR/kitty/theme.conf'",
|
||||
shellDir: "/home/user/shell",
|
||||
expected: "output_path = '" + configDir + "/kitty/theme.conf'",
|
||||
},
|
||||
{
|
||||
name: "substitutes DATA_DIR",
|
||||
input: "output_path = 'DATA_DIR/color-schemes/theme.colors'",
|
||||
shellDir: "/home/user/shell",
|
||||
expected: "output_path = '" + dataDir + "/color-schemes/theme.colors'",
|
||||
},
|
||||
{
|
||||
name: "substitutes CACHE_DIR",
|
||||
input: "output_path = 'CACHE_DIR/wal/colors.json'",
|
||||
shellDir: "/home/user/shell",
|
||||
expected: "output_path = '" + cacheDir + "/wal/colors.json'",
|
||||
},
|
||||
{
|
||||
name: "substitutes all dir types",
|
||||
input: "'SHELL_DIR/a' 'CONFIG_DIR/b' 'DATA_DIR/c' 'CACHE_DIR/d'",
|
||||
shellDir: "/shell",
|
||||
expected: "'/shell/a' '" + configDir + "/b' '" + dataDir + "/c' '" + cacheDir + "/d'",
|
||||
},
|
||||
{
|
||||
name: "no substitution when no placeholders",
|
||||
input: "input_path = '/absolute/path/foo.conf'",
|
||||
shellDir: "/home/user/shell",
|
||||
expected: "input_path = '/absolute/path/foo.conf'",
|
||||
},
|
||||
{
|
||||
name: "multiple SHELL_DIR occurrences",
|
||||
input: "'SHELL_DIR/a' and 'SHELL_DIR/b'",
|
||||
shellDir: "/shell",
|
||||
expected: "'/shell/a' and '/shell/b'",
|
||||
},
|
||||
{
|
||||
name: "only substitutes quoted paths",
|
||||
input: "SHELL_DIR/unquoted and 'SHELL_DIR/quoted'",
|
||||
shellDir: "/shell",
|
||||
expected: "SHELL_DIR/unquoted and '/shell/quoted'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := substituteVars(tc.input, tc.shellDir)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
242
core/internal/mocks/utils/mock_AppChecker.go
Normal file
242
core/internal/mocks/utils/mock_AppChecker.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
||||
|
||||
package mocks_utils
|
||||
|
||||
import mock "github.com/stretchr/testify/mock"
|
||||
|
||||
// MockAppChecker is an autogenerated mock type for the AppChecker type
|
||||
type MockAppChecker struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockAppChecker_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockAppChecker) EXPECT() *MockAppChecker_Expecter {
|
||||
return &MockAppChecker_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// AnyCommandExists provides a mock function with given fields: cmds
|
||||
func (_m *MockAppChecker) AnyCommandExists(cmds ...string) bool {
|
||||
_va := make([]interface{}, len(cmds))
|
||||
for _i := range cmds {
|
||||
_va[_i] = cmds[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for AnyCommandExists")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(...string) bool); ok {
|
||||
r0 = rf(cmds...)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAppChecker_AnyCommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyCommandExists'
|
||||
type MockAppChecker_AnyCommandExists_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// AnyCommandExists is a helper method to define mock.On call
|
||||
// - cmds ...string
|
||||
func (_e *MockAppChecker_Expecter) AnyCommandExists(cmds ...interface{}) *MockAppChecker_AnyCommandExists_Call {
|
||||
return &MockAppChecker_AnyCommandExists_Call{Call: _e.mock.On("AnyCommandExists",
|
||||
append([]interface{}{}, cmds...)...)}
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_AnyCommandExists_Call) Run(run func(cmds ...string)) *MockAppChecker_AnyCommandExists_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
variadicArgs := make([]string, len(args)-0)
|
||||
for i, a := range args[0:] {
|
||||
if a != nil {
|
||||
variadicArgs[i] = a.(string)
|
||||
}
|
||||
}
|
||||
run(variadicArgs...)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_AnyCommandExists_Call) Return(_a0 bool) *MockAppChecker_AnyCommandExists_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_AnyCommandExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyCommandExists_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// AnyFlatpakExists provides a mock function with given fields: flatpaks
|
||||
func (_m *MockAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
|
||||
_va := make([]interface{}, len(flatpaks))
|
||||
for _i := range flatpaks {
|
||||
_va[_i] = flatpaks[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for AnyFlatpakExists")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(...string) bool); ok {
|
||||
r0 = rf(flatpaks...)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAppChecker_AnyFlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyFlatpakExists'
|
||||
type MockAppChecker_AnyFlatpakExists_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// AnyFlatpakExists is a helper method to define mock.On call
|
||||
// - flatpaks ...string
|
||||
func (_e *MockAppChecker_Expecter) AnyFlatpakExists(flatpaks ...interface{}) *MockAppChecker_AnyFlatpakExists_Call {
|
||||
return &MockAppChecker_AnyFlatpakExists_Call{Call: _e.mock.On("AnyFlatpakExists",
|
||||
append([]interface{}{}, flatpaks...)...)}
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_AnyFlatpakExists_Call) Run(run func(flatpaks ...string)) *MockAppChecker_AnyFlatpakExists_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
variadicArgs := make([]string, len(args)-0)
|
||||
for i, a := range args[0:] {
|
||||
if a != nil {
|
||||
variadicArgs[i] = a.(string)
|
||||
}
|
||||
}
|
||||
run(variadicArgs...)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_AnyFlatpakExists_Call) Return(_a0 bool) *MockAppChecker_AnyFlatpakExists_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_AnyFlatpakExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyFlatpakExists_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// CommandExists provides a mock function with given fields: cmd
|
||||
func (_m *MockAppChecker) CommandExists(cmd string) bool {
|
||||
ret := _m.Called(cmd)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CommandExists")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string) bool); ok {
|
||||
r0 = rf(cmd)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAppChecker_CommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommandExists'
|
||||
type MockAppChecker_CommandExists_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CommandExists is a helper method to define mock.On call
|
||||
// - cmd string
|
||||
func (_e *MockAppChecker_Expecter) CommandExists(cmd interface{}) *MockAppChecker_CommandExists_Call {
|
||||
return &MockAppChecker_CommandExists_Call{Call: _e.mock.On("CommandExists", cmd)}
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_CommandExists_Call) Run(run func(cmd string)) *MockAppChecker_CommandExists_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_CommandExists_Call) Return(_a0 bool) *MockAppChecker_CommandExists_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_CommandExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_CommandExists_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// FlatpakExists provides a mock function with given fields: name
|
||||
func (_m *MockAppChecker) FlatpakExists(name string) bool {
|
||||
ret := _m.Called(name)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for FlatpakExists")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string) bool); ok {
|
||||
r0 = rf(name)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAppChecker_FlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FlatpakExists'
|
||||
type MockAppChecker_FlatpakExists_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// FlatpakExists is a helper method to define mock.On call
|
||||
// - name string
|
||||
func (_e *MockAppChecker_Expecter) FlatpakExists(name interface{}) *MockAppChecker_FlatpakExists_Call {
|
||||
return &MockAppChecker_FlatpakExists_Call{Call: _e.mock.On("FlatpakExists", name)}
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_FlatpakExists_Call) Run(run func(name string)) *MockAppChecker_FlatpakExists_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_FlatpakExists_Call) Return(_a0 bool) *MockAppChecker_FlatpakExists_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAppChecker_FlatpakExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_FlatpakExists_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockAppChecker creates a new instance of MockAppChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockAppChecker(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockAppChecker {
|
||||
mock := &MockAppChecker{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
170
core/internal/notify/notify.go
Normal file
170
core/internal/notify/notify.go
Normal file
@@ -0,0 +1,170 @@
|
||||
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(¬ificationID); 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()
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type Plugin struct {
|
||||
Compositors []string `json:"compositors"`
|
||||
Distro []string `json:"distro"`
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
RequiresDMS string `json:"requires_dms,omitempty"`
|
||||
}
|
||||
|
||||
type GitClient interface {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -110,17 +111,15 @@ 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 = powered
|
||||
m.state.Discovering = discovering
|
||||
m.state.Powered = dbusutil.AsOr(poweredVar, false)
|
||||
m.state.Discovering = dbusutil.AsOr(discoveringVar, false)
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -169,65 +168,20 @@ func (m *Manager) updateDevices() error {
|
||||
}
|
||||
|
||||
func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
|
||||
dev := Device{Path: path}
|
||||
|
||||
if v, ok := props["Address"]; ok {
|
||||
if addr, ok := v.Value().(string); ok {
|
||||
dev.Address = addr
|
||||
}
|
||||
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),
|
||||
}
|
||||
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 {
|
||||
@@ -328,17 +282,13 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant
|
||||
m.stateMutex.Lock()
|
||||
dirty := false
|
||||
|
||||
if v, ok := changed["Powered"]; ok {
|
||||
if powered, ok := v.Value().(bool); ok {
|
||||
m.state.Powered = powered
|
||||
dirty = true
|
||||
}
|
||||
if powered, ok := dbusutil.Get[bool](changed, "Powered"); ok {
|
||||
m.state.Powered = powered
|
||||
dirty = true
|
||||
}
|
||||
if v, ok := changed["Discovering"]; ok {
|
||||
if discovering, ok := v.Value().(bool); ok {
|
||||
m.state.Discovering = discovering
|
||||
dirty = true
|
||||
}
|
||||
if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok {
|
||||
m.state.Discovering = discovering
|
||||
dirty = true
|
||||
}
|
||||
|
||||
m.stateMutex.Unlock()
|
||||
@@ -349,31 +299,28 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant
|
||||
}
|
||||
|
||||
func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
|
||||
pairedVar, hasPaired := changed["Paired"]
|
||||
paired, hasPaired := dbusutil.Get[bool](changed, "Paired")
|
||||
_, hasConnected := changed["Connected"]
|
||||
_, hasTrusted := changed["Trusted"]
|
||||
|
||||
if hasPaired {
|
||||
devicePath := string(path)
|
||||
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:
|
||||
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:
|
||||
}
|
||||
} else {
|
||||
m.pendingPairings.Delete(devicePath)
|
||||
}
|
||||
} else {
|
||||
m.pendingPairings.Delete(devicePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,14 @@ 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)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
|
||||
}
|
||||
@@ -205,6 +213,9 @@ 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())
|
||||
@@ -230,3 +241,43 @@ 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})
|
||||
}
|
||||
|
||||
@@ -24,20 +24,21 @@ import (
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
// These mime types wont be stored in history
|
||||
// These mime types won't be stored in history
|
||||
var sensitiveMimeTypes = []string{
|
||||
"x-kde-passwordManagerHint",
|
||||
}
|
||||
|
||||
func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) {
|
||||
display := wlCtx.Display()
|
||||
dbPath, err := getDBPath()
|
||||
dbPath, err := clipboardstore.GetDBPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get db path: %w", err)
|
||||
}
|
||||
@@ -102,24 +103,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func getDBPath() (string, error) {
|
||||
cacheDir := os.Getenv("XDG_CACHE_HOME")
|
||||
if cacheDir == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cacheDir = filepath.Join(homeDir, ".cache")
|
||||
}
|
||||
|
||||
dbDir := filepath.Join(cacheDir, "dms-clipboard")
|
||||
if err := os.MkdirAll(dbDir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dbDir, "db"), nil
|
||||
}
|
||||
|
||||
func openDB(path string) (*bolt.DB, error) {
|
||||
db, err := bolt.Open(path, 0644, &bolt.Options{
|
||||
Timeout: 1 * time.Second,
|
||||
@@ -406,7 +389,11 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
|
||||
}
|
||||
c := b.Cursor()
|
||||
var count int
|
||||
for k, _ := c.Last(); k != nil; k, _ = c.Prev() {
|
||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
||||
entry, err := decodeEntry(v)
|
||||
if err == nil && entry.Pinned {
|
||||
continue
|
||||
}
|
||||
if count < m.config.MaxHistory {
|
||||
count++
|
||||
continue
|
||||
@@ -436,6 +423,11 @@ 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
|
||||
}
|
||||
@@ -479,6 +471,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -752,19 +750,54 @@ func (m *Manager) ClearHistory() {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete only non-pinned entries
|
||||
if err := m.db.Update(func(tx *bolt.Tx) error {
|
||||
if err := tx.DeleteBucket([]byte("clipboard")); err != nil {
|
||||
return err
|
||||
b := tx.Bucket([]byte("clipboard"))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.CreateBucket([]byte("clipboard"))
|
||||
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 != nil {
|
||||
log.Errorf("Failed to clear clipboard history: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.compactDB(); err != nil {
|
||||
log.Errorf("Failed to compact database: %v", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
m.updateState()
|
||||
@@ -977,6 +1010,10 @@ 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)
|
||||
}
|
||||
@@ -1267,3 +1304,153 @@ 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
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Config struct {
|
||||
AutoClearDays int `json:"autoClearDays"`
|
||||
ClearAtStartup bool `json:"clearAtStartup"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MaxPinned int `json:"maxPinned"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
@@ -27,6 +28,7 @@ func DefaultConfig() Config {
|
||||
MaxEntrySize: 5 * 1024 * 1024,
|
||||
AutoClearDays: 0,
|
||||
ClearAtStartup: false,
|
||||
MaxPinned: 25,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +102,7 @@ type Entry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
IsImage bool `json:"isImage"`
|
||||
Hash uint64 `json:"hash,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
|
||||
237
core/internal/server/dbus/handlers.go
Normal file
237
core/internal/server/dbus/handlers.go
Normal file
@@ -0,0 +1,237 @@
|
||||
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})
|
||||
}
|
||||
362
core/internal/server/dbus/manager.go
Normal file
362
core/internal/server/dbus/manager.go
Normal file
@@ -0,0 +1,362 @@
|
||||
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
|
||||
|
||||
call := obj.Call(fullMethod, 0, args...)
|
||||
if call.Err != nil {
|
||||
return nil, fmt.Errorf("dbus call failed: %w", call.Err)
|
||||
}
|
||||
|
||||
return &CallResult{Values: call.Body}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
52
core/internal/server/dbus/types.go
Normal file
52
core/internal/server/dbus/types.go
Normal file
@@ -0,0 +1,52 @@
|
||||
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"`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -110,61 +111,17 @@ func (m *Manager) updateAccountsState() error {
|
||||
m.stateMutex.Lock()
|
||||
defer m.stateMutex.Unlock()
|
||||
|
||||
if v, ok := props["IconFile"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.IconFile = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["RealName"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.RealName = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["UserName"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.UserName = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["AccountType"]; ok {
|
||||
if val, ok := v.Value().(int32); ok {
|
||||
m.state.Accounts.AccountType = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["HomeDirectory"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.HomeDirectory = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["Shell"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.Shell = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["Email"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.Email = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["Language"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.Language = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["Location"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.Accounts.Location = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["Locked"]; ok {
|
||||
if val, ok := v.Value().(bool); ok {
|
||||
m.state.Accounts.Locked = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["PasswordMode"]; ok {
|
||||
if val, ok := v.Value().(int32); ok {
|
||||
m.state.Accounts.PasswordMode = val
|
||||
}
|
||||
}
|
||||
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))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -180,7 +137,7 @@ func (m *Manager) updateSettingsState() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if colorScheme, ok := variant.Value().(uint32); ok {
|
||||
if colorScheme, ok := dbusutil.As[uint32](variant); ok {
|
||||
m.stateMutex.Lock()
|
||||
m.state.Settings.ColorScheme = colorScheme
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -132,37 +133,15 @@ func (m *Manager) updateSessionState() error {
|
||||
m.stateMutex.Lock()
|
||||
defer m.stateMutex.Unlock()
|
||||
|
||||
if v, ok := props["Active"]; ok {
|
||||
if val, ok := v.Value().(bool); ok {
|
||||
m.state.Active = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["IdleHint"]; ok {
|
||||
if val, ok := v.Value().(bool); ok {
|
||||
m.state.IdleHint = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["IdleSinceHint"]; ok {
|
||||
if val, ok := v.Value().(uint64); ok {
|
||||
m.state.IdleSinceHint = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["LockedHint"]; ok {
|
||||
if val, ok := v.Value().(bool); ok {
|
||||
m.state.LockedHint = val
|
||||
m.state.Locked = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["Type"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.SessionType = val
|
||||
}
|
||||
}
|
||||
if v, ok := props["Class"]; ok {
|
||||
if val, ok := v.Value().(string); ok {
|
||||
m.state.SessionClass = val
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
@@ -170,36 +149,12 @@ func (m *Manager) updateSessionState() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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["Seat"]; ok {
|
||||
if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
|
||||
if seatID, ok := seatArr[0].(string); ok {
|
||||
@@ -207,11 +162,7 @@ func (m *Manager) updateSessionState() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := props["VTNr"]; ok {
|
||||
if val, ok := v.Value().(uint32); ok {
|
||||
m.state.VTNr = val
|
||||
}
|
||||
}
|
||||
m.state.VTNr = dbusutil.GetOr(props, "VTNr", m.state.VTNr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package loginctl
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -117,31 +118,28 @@ func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) {
|
||||
for key, variant := range changes {
|
||||
switch key {
|
||||
case "Active":
|
||||
if val, ok := variant.Value().(bool); ok {
|
||||
if val, ok := dbusutil.As[bool](variant); ok {
|
||||
m.stateMutex.Lock()
|
||||
m.state.Active = val
|
||||
m.stateMutex.Unlock()
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
case "IdleHint":
|
||||
if val, ok := variant.Value().(bool); ok {
|
||||
if val, ok := dbusutil.As[bool](variant); ok {
|
||||
m.stateMutex.Lock()
|
||||
m.state.IdleHint = val
|
||||
m.stateMutex.Unlock()
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
case "IdleSinceHint":
|
||||
if val, ok := variant.Value().(uint64); ok {
|
||||
if val, ok := dbusutil.As[uint64](variant); ok {
|
||||
m.stateMutex.Lock()
|
||||
m.state.IdleSinceHint = val
|
||||
m.stateMutex.Unlock()
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
case "LockedHint":
|
||||
if val, ok := variant.Value().(bool); ok {
|
||||
if val, ok := dbusutil.As[bool](variant); ok {
|
||||
m.stateMutex.Lock()
|
||||
m.state.LockedHint = val
|
||||
m.state.Locked = val
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
dbusNMPath = "/org/freedesktop/NetworkManager"
|
||||
dbusNMInterface = "org.freedesktop.NetworkManager"
|
||||
dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device"
|
||||
dbusNMWiredInterface = "org.freedesktop.NetworkManager.Device.Wired"
|
||||
dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless"
|
||||
dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint"
|
||||
dbusPropsInterface = "org.freedesktop.DBus.Properties"
|
||||
|
||||
@@ -81,44 +81,24 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.wifiDevice != nil {
|
||||
dev := b.wifiDevice.(gonetworkmanager.Device)
|
||||
for _, info := range b.wifiDevices {
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
); err != nil {
|
||||
conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
conn.RemoveSignal(signals)
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if b.ethernetDevice != nil {
|
||||
dev := b.ethernetDevice.(gonetworkmanager.Device)
|
||||
for _, info := range b.ethernetDevices {
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
); err != nil {
|
||||
conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
if b.wifiDevice != nil {
|
||||
dev := b.wifiDevice.(gonetworkmanager.Device)
|
||||
conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
conn.RemoveSignal(signals)
|
||||
conn.Close()
|
||||
return err
|
||||
@@ -157,19 +137,17 @@ func (b *NetworkManagerBackend) stopSignalPump() {
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
|
||||
if b.wifiDevice != nil {
|
||||
dev := b.wifiDevice.(gonetworkmanager.Device)
|
||||
for _, info := range b.wifiDevices {
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
|
||||
if b.ethernetDevice != nil {
|
||||
dev := b.ethernetDevice.(gonetworkmanager.Device)
|
||||
for _, info := range b.ethernetDevices {
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
@@ -232,7 +210,10 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
|
||||
b.handleNetworkManagerChange(changes)
|
||||
|
||||
case dbusNMDeviceInterface:
|
||||
b.handleDeviceChange(changes)
|
||||
b.handleDeviceChange(sig.Path, changes)
|
||||
|
||||
case dbusNMWiredInterface:
|
||||
b.handleWiredChange(changes)
|
||||
|
||||
case dbusNMWirelessInterface:
|
||||
b.handleWiFiChange(changes)
|
||||
@@ -278,9 +259,10 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db
|
||||
}
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Variant) {
|
||||
func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, changes map[string]dbus.Variant) {
|
||||
var needsUpdate bool
|
||||
var stateChanged bool
|
||||
var managedChanged bool
|
||||
|
||||
for key := range changes {
|
||||
switch key {
|
||||
@@ -289,21 +271,61 @@ func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Varia
|
||||
needsUpdate = true
|
||||
case "Ip4Config":
|
||||
needsUpdate = true
|
||||
case "Managed":
|
||||
managedChanged = true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if needsUpdate {
|
||||
b.updateEthernetState()
|
||||
b.updateWiFiState()
|
||||
if stateChanged {
|
||||
b.updatePrimaryConnection()
|
||||
if managedChanged {
|
||||
if managedVariant, ok := changes["Managed"]; ok {
|
||||
if managed, ok := managedVariant.Value().(bool); ok && managed {
|
||||
b.handleDeviceAdded(devicePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
|
||||
if !needsUpdate {
|
||||
return
|
||||
}
|
||||
|
||||
b.updateAllEthernetDevices()
|
||||
b.updateEthernetState()
|
||||
b.updateAllWiFiDevices()
|
||||
b.updateWiFiState()
|
||||
if stateChanged {
|
||||
b.listEthernetConnections()
|
||||
b.updatePrimaryConnection()
|
||||
}
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) handleWiredChange(changes map[string]dbus.Variant) {
|
||||
var needsUpdate bool
|
||||
|
||||
for key := range changes {
|
||||
switch key {
|
||||
case "Carrier", "Speed", "HwAddress":
|
||||
needsUpdate = true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !needsUpdate {
|
||||
return
|
||||
}
|
||||
|
||||
b.updateAllEthernetDevices()
|
||||
b.updateEthernetState()
|
||||
b.updatePrimaryConnection()
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) {
|
||||
@@ -369,6 +391,18 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi {
|
||||
return
|
||||
}
|
||||
|
||||
if b.dbusConn != nil {
|
||||
b.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(devicePath),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
|
||||
managed, _ := dev.GetPropertyManaged()
|
||||
if !managed {
|
||||
return
|
||||
@@ -398,14 +432,6 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
|
||||
b.ethernetDevice = dev
|
||||
}
|
||||
|
||||
if b.dbusConn != nil {
|
||||
b.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(devicePath),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
|
||||
b.updateAllEthernetDevices()
|
||||
b.updateEthernetState()
|
||||
b.listEthernetConnections()
|
||||
@@ -430,14 +456,6 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
|
||||
b.wifiDev = w
|
||||
}
|
||||
|
||||
if b.dbusConn != nil {
|
||||
b.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(devicePath),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
}
|
||||
|
||||
b.updateAllWiFiDevices()
|
||||
b.updateWiFiState()
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
backend.handleDeviceChange(changes)
|
||||
backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
backend.handleDeviceChange(changes)
|
||||
backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package network
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -925,25 +926,24 @@ func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImp
|
||||
func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) {
|
||||
vpnTypes := []string{"openvpn", "wireguard", "vpnc", "pptp", "l2tp", "openconnect", "strongswan"}
|
||||
|
||||
var output []byte
|
||||
var err error
|
||||
var allErrors []error
|
||||
var outputStr string
|
||||
for _, vpnType := range vpnTypes {
|
||||
args := []string{"connection", "import", "type", vpnType, "file", filePath}
|
||||
cmd := exec.Command("nmcli", args...)
|
||||
output, err = cmd.CombinedOutput()
|
||||
cmd := exec.Command("nmcli", "connection", "import", "type", vpnType, "file", filePath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
outputStr = string(output)
|
||||
break
|
||||
}
|
||||
allErrors = append(allErrors, fmt.Errorf("%s: %s", vpnType, strings.TrimSpace(string(output))))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if len(allErrors) == len(vpnTypes) {
|
||||
return &VPNImportResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("import failed: %s", strings.TrimSpace(string(output))),
|
||||
Error: errors.Join(allErrors...).Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
outputStr := string(output)
|
||||
var connUUID, connName string
|
||||
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
|
||||
@@ -357,31 +357,51 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
|
||||
savedSSIDs := make(map[string]bool)
|
||||
autoconnectMap := make(map[string]bool)
|
||||
hiddenSSIDs := make(map[string]bool)
|
||||
for _, conn := range connections {
|
||||
connSettings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if connMeta, ok := connSettings["connection"]; ok {
|
||||
if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" {
|
||||
if wifiSettings, ok := connSettings["802-11-wireless"]; ok {
|
||||
if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok {
|
||||
ssid := string(ssidBytes)
|
||||
savedSSIDs[ssid] = true
|
||||
autoconnect := true
|
||||
if ac, ok := connMeta["autoconnect"].(bool); ok {
|
||||
autoconnect = ac
|
||||
}
|
||||
autoconnectMap[ssid] = autoconnect
|
||||
}
|
||||
}
|
||||
}
|
||||
connMeta, ok := connSettings["connection"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
connType, ok := connMeta["type"].(string)
|
||||
if !ok || connType != "802-11-wireless" {
|
||||
continue
|
||||
}
|
||||
|
||||
wifiSettings, ok := connSettings["802-11-wireless"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ssidBytes, ok := wifiSettings["ssid"].([]byte)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ssid := string(ssidBytes)
|
||||
savedSSIDs[ssid] = true
|
||||
autoconnect := true
|
||||
if ac, ok := connMeta["autoconnect"].(bool); ok {
|
||||
autoconnect = ac
|
||||
}
|
||||
autoconnectMap[ssid] = autoconnect
|
||||
|
||||
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
|
||||
hiddenSSIDs[ssid] = true
|
||||
}
|
||||
}
|
||||
|
||||
b.stateMutex.RLock()
|
||||
currentSSID := b.state.WiFiSSID
|
||||
wifiConnected := b.state.WiFiConnected
|
||||
wifiSignal := b.state.WiFiSignal
|
||||
wifiBSSID := b.state.WiFiBSSID
|
||||
b.stateMutex.RUnlock()
|
||||
|
||||
seenSSIDs := make(map[string]*WiFiNetwork)
|
||||
@@ -444,6 +464,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
Connected: ssid == currentSSID,
|
||||
Saved: savedSSIDs[ssid],
|
||||
Autoconnect: autoconnectMap[ssid],
|
||||
Hidden: hiddenSSIDs[ssid],
|
||||
Frequency: freq,
|
||||
Mode: modeStr,
|
||||
Rate: maxBitrate / 1000,
|
||||
@@ -454,6 +475,23 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
networks = append(networks, network)
|
||||
}
|
||||
|
||||
if wifiConnected && currentSSID != "" {
|
||||
if _, exists := seenSSIDs[currentSSID]; !exists {
|
||||
hiddenNetwork := WiFiNetwork{
|
||||
SSID: currentSSID,
|
||||
BSSID: wifiBSSID,
|
||||
Signal: wifiSignal,
|
||||
Secured: true,
|
||||
Connected: true,
|
||||
Saved: savedSSIDs[currentSSID],
|
||||
Autoconnect: autoconnectMap[currentSSID],
|
||||
Hidden: true,
|
||||
Mode: "infrastructure",
|
||||
}
|
||||
networks = append(networks, hiddenNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
sortWiFiNetworks(networks)
|
||||
|
||||
b.stateMutex.Lock()
|
||||
@@ -515,40 +553,53 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
||||
nm := b.nmConn.(gonetworkmanager.NetworkManager)
|
||||
dev := devInfo.device
|
||||
w := devInfo.wireless
|
||||
apPaths, err := w.GetAccessPoints()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get access points: %w", err)
|
||||
}
|
||||
|
||||
var targetAP gonetworkmanager.AccessPoint
|
||||
for _, ap := range apPaths {
|
||||
ssid, err := ap.GetPropertySSID()
|
||||
if err != nil || ssid != req.SSID {
|
||||
continue
|
||||
var flags, wpaFlags, rsnFlags uint32
|
||||
|
||||
if !req.Hidden {
|
||||
apPaths, err := w.GetAccessPoints()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get access points: %w", err)
|
||||
}
|
||||
targetAP = ap
|
||||
break
|
||||
}
|
||||
|
||||
if targetAP == nil {
|
||||
return fmt.Errorf("access point not found: %s", req.SSID)
|
||||
}
|
||||
for _, ap := range apPaths {
|
||||
ssid, err := ap.GetPropertySSID()
|
||||
if err != nil || ssid != req.SSID {
|
||||
continue
|
||||
}
|
||||
targetAP = ap
|
||||
break
|
||||
}
|
||||
|
||||
flags, _ := targetAP.GetPropertyFlags()
|
||||
wpaFlags, _ := targetAP.GetPropertyWPAFlags()
|
||||
rsnFlags, _ := targetAP.GetPropertyRSNFlags()
|
||||
if targetAP == nil {
|
||||
return fmt.Errorf("access point not found: %s", req.SSID)
|
||||
}
|
||||
|
||||
flags, _ = targetAP.GetPropertyFlags()
|
||||
wpaFlags, _ = targetAP.GetPropertyWPAFlags()
|
||||
rsnFlags, _ = targetAP.GetPropertyRSNFlags()
|
||||
}
|
||||
|
||||
const KeyMgmt8021x = uint32(512)
|
||||
const KeyMgmtPsk = uint32(256)
|
||||
const KeyMgmtSae = uint32(1024)
|
||||
|
||||
isEnterprise := (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0
|
||||
isPsk := (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
|
||||
isSae := (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
|
||||
var isEnterprise, isPsk, isSae, secured bool
|
||||
|
||||
secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
|
||||
wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
|
||||
rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
|
||||
switch {
|
||||
case req.Hidden:
|
||||
secured = req.Password != "" || req.Username != ""
|
||||
isEnterprise = req.Username != ""
|
||||
isPsk = req.Password != "" && !isEnterprise
|
||||
default:
|
||||
isEnterprise = (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0
|
||||
isPsk = (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
|
||||
isSae = (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
|
||||
secured = flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
|
||||
wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
|
||||
rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
|
||||
}
|
||||
|
||||
if isEnterprise {
|
||||
log.Infof("[createAndConnectWiFi] Enterprise network detected (802.1x) - SSID: %s, interactive: %v",
|
||||
@@ -567,11 +618,15 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
||||
settings["ipv6"] = map[string]any{"method": "auto"}
|
||||
|
||||
if secured {
|
||||
settings["802-11-wireless"] = map[string]any{
|
||||
wifiSettings := map[string]any{
|
||||
"ssid": []byte(req.SSID),
|
||||
"mode": "infrastructure",
|
||||
"security": "802-11-wireless-security",
|
||||
}
|
||||
if req.Hidden {
|
||||
wifiSettings["hidden"] = true
|
||||
}
|
||||
settings["802-11-wireless"] = wifiSettings
|
||||
|
||||
switch {
|
||||
case isEnterprise || req.Username != "":
|
||||
@@ -658,10 +713,14 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
||||
return fmt.Errorf("secured network but not SAE/PSK/802.1X (rsn=0x%x wpa=0x%x)", rsnFlags, wpaFlags)
|
||||
}
|
||||
} else {
|
||||
settings["802-11-wireless"] = map[string]any{
|
||||
wifiSettings := map[string]any{
|
||||
"ssid": []byte(req.SSID),
|
||||
"mode": "infrastructure",
|
||||
}
|
||||
if req.Hidden {
|
||||
wifiSettings["hidden"] = true
|
||||
}
|
||||
settings["802-11-wireless"] = wifiSettings
|
||||
}
|
||||
|
||||
if req.Interactive {
|
||||
@@ -685,14 +744,23 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
||||
log.Infof("[createAndConnectWiFi] Enterprise connection added, activating (secret agent will be called)")
|
||||
}
|
||||
|
||||
_, err = nm.ActivateWirelessConnection(conn, dev, targetAP)
|
||||
if req.Hidden {
|
||||
_, err = nm.ActivateConnection(conn, dev, nil)
|
||||
} else {
|
||||
_, err = nm.ActivateWirelessConnection(conn, dev, targetAP)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to activate connection: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...")
|
||||
} else {
|
||||
_, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP)
|
||||
var err error
|
||||
if req.Hidden {
|
||||
_, err = nm.AddAndActivateConnection(settings, dev)
|
||||
} else {
|
||||
_, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
@@ -813,6 +881,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
|
||||
savedSSIDs := make(map[string]bool)
|
||||
autoconnectMap := make(map[string]bool)
|
||||
hiddenSSIDs := make(map[string]bool)
|
||||
for _, conn := range connections {
|
||||
connSettings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
@@ -846,6 +915,10 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
autoconnect = ac
|
||||
}
|
||||
autoconnectMap[ssid] = autoconnect
|
||||
|
||||
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
|
||||
hiddenSSIDs[ssid] = true
|
||||
}
|
||||
}
|
||||
|
||||
var devices []WiFiDevice
|
||||
@@ -939,6 +1012,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
Connected: connected && apSSID == ssid,
|
||||
Saved: savedSSIDs[apSSID],
|
||||
Autoconnect: autoconnectMap[apSSID],
|
||||
Hidden: hiddenSSIDs[apSSID],
|
||||
Frequency: freq,
|
||||
Mode: modeStr,
|
||||
Rate: maxBitrate / 1000,
|
||||
@@ -949,6 +1023,25 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
seenSSIDs[apSSID] = &network
|
||||
networks = append(networks, network)
|
||||
}
|
||||
|
||||
if connected && ssid != "" {
|
||||
if _, exists := seenSSIDs[ssid]; !exists {
|
||||
hiddenNetwork := WiFiNetwork{
|
||||
SSID: ssid,
|
||||
BSSID: bssid,
|
||||
Signal: signal,
|
||||
Secured: true,
|
||||
Connected: true,
|
||||
Saved: savedSSIDs[ssid],
|
||||
Autoconnect: autoconnectMap[ssid],
|
||||
Hidden: true,
|
||||
Mode: "infrastructure",
|
||||
Device: name,
|
||||
}
|
||||
networks = append(networks, hiddenNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
sortWiFiNetworks(networks)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,21 @@ package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/Wifx/gonetworkmanager/v2"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
priorityHigh = int32(100)
|
||||
priorityLow = int32(10)
|
||||
priorityDefault = int32(0)
|
||||
|
||||
metricPreferred = int64(100)
|
||||
metricNonPreferred = int64(300)
|
||||
metricDefault = int64(100)
|
||||
)
|
||||
|
||||
func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
|
||||
@@ -36,83 +48,116 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
|
||||
}
|
||||
|
||||
func (m *Manager) prioritizeWiFi() error {
|
||||
if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil {
|
||||
return err
|
||||
if err := m.setConnectionPriority("802-11-wireless", priorityHigh, metricPreferred); err != nil {
|
||||
log.Warnf("Failed to set WiFi priority: %v", err)
|
||||
}
|
||||
|
||||
if err := m.setConnectionMetrics("802-3-ethernet", 100); err != nil {
|
||||
return err
|
||||
if err := m.setConnectionPriority("802-3-ethernet", priorityLow, metricNonPreferred); err != nil {
|
||||
log.Warnf("Failed to set Ethernet priority: %v", err)
|
||||
}
|
||||
|
||||
m.reapplyActiveConnections()
|
||||
m.notifySubscribers()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) prioritizeEthernet() error {
|
||||
if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil {
|
||||
return err
|
||||
if err := m.setConnectionPriority("802-3-ethernet", priorityHigh, metricPreferred); err != nil {
|
||||
log.Warnf("Failed to set Ethernet priority: %v", err)
|
||||
}
|
||||
|
||||
if err := m.setConnectionMetrics("802-11-wireless", 100); err != nil {
|
||||
return err
|
||||
if err := m.setConnectionPriority("802-11-wireless", priorityLow, metricNonPreferred); err != nil {
|
||||
log.Warnf("Failed to set WiFi priority: %v", err)
|
||||
}
|
||||
|
||||
m.reapplyActiveConnections()
|
||||
m.notifySubscribers()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) balancePriorities() error {
|
||||
if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil {
|
||||
return err
|
||||
if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil {
|
||||
log.Warnf("Failed to reset Ethernet priority: %v", err)
|
||||
}
|
||||
|
||||
if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil {
|
||||
return err
|
||||
if err := m.setConnectionPriority("802-11-wireless", priorityDefault, metricDefault); err != nil {
|
||||
log.Warnf("Failed to reset WiFi priority: %v", err)
|
||||
}
|
||||
|
||||
m.reapplyActiveConnections()
|
||||
m.notifySubscribers()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) setConnectionMetrics(connType string, metric uint32) error {
|
||||
settingsMgr, err := gonetworkmanager.NewSettings()
|
||||
func (m *Manager) reapplyActiveConnections() {
|
||||
m.stateMutex.RLock()
|
||||
ethDev := m.state.EthernetDevice
|
||||
wifiDev := m.state.WiFiDevice
|
||||
m.stateMutex.RUnlock()
|
||||
|
||||
if ethDev != "" {
|
||||
exec.Command("nmcli", "dev", "reapply", ethDev).Run()
|
||||
}
|
||||
if wifiDev != "" {
|
||||
exec.Command("nmcli", "dev", "reapply", wifiDev).Run()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int32, routeMetric int64) error {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get settings: %w", err)
|
||||
return fmt.Errorf("failed to connect to system bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
settingsObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings")
|
||||
|
||||
var connPaths []dbus.ObjectPath
|
||||
if err := settingsObj.Call("org.freedesktop.NetworkManager.Settings.ListConnections", 0).Store(&connPaths); err != nil {
|
||||
return fmt.Errorf("failed to list connections: %w", err)
|
||||
}
|
||||
|
||||
connections, err := settingsMgr.ListConnections()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get connections: %w", err)
|
||||
}
|
||||
for _, connPath := range connPaths {
|
||||
connObj := conn.Object("org.freedesktop.NetworkManager", connPath)
|
||||
|
||||
for _, conn := range connections {
|
||||
connSettings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
var settings map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&settings); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if connMeta, ok := connSettings["connection"]; ok {
|
||||
if cType, ok := connMeta["type"].(string); ok && cType == connType {
|
||||
if connSettings["ipv4"] == nil {
|
||||
connSettings["ipv4"] = make(map[string]any)
|
||||
}
|
||||
if ipv4Map := connSettings["ipv4"]; ipv4Map != nil {
|
||||
ipv4Map["route-metric"] = int64(metric)
|
||||
}
|
||||
|
||||
if connSettings["ipv6"] == nil {
|
||||
connSettings["ipv6"] = make(map[string]any)
|
||||
}
|
||||
if ipv6Map := connSettings["ipv6"]; ipv6Map != nil {
|
||||
ipv6Map["route-metric"] = int64(metric)
|
||||
}
|
||||
|
||||
err = conn.Update(connSettings)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
connSection, ok := settings["connection"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
typeVariant, ok := connSection["type"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
cType, ok := typeVariant.Value().(string)
|
||||
if !ok || cType != connType {
|
||||
continue
|
||||
}
|
||||
|
||||
connName := ""
|
||||
if idVariant, ok := connSection["id"]; ok {
|
||||
connName, _ = idVariant.Value().(string)
|
||||
}
|
||||
|
||||
if connName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -125,14 +170,18 @@ func (m *Manager) GetConnectionPreference() ConnectionPreference {
|
||||
}
|
||||
|
||||
func (m *Manager) WasRecentlyFailed(ssid string) bool {
|
||||
if nm, ok := m.backend.(*NetworkManagerBackend); ok {
|
||||
nm.failedMutex.RLock()
|
||||
defer nm.failedMutex.RUnlock()
|
||||
|
||||
if nm.lastFailedSSID == ssid {
|
||||
elapsed := time.Now().Unix() - nm.lastFailedTime
|
||||
return elapsed < 10
|
||||
}
|
||||
nm, ok := m.backend.(*NetworkManagerBackend)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
|
||||
nm.failedMutex.RLock()
|
||||
defer nm.failedMutex.RUnlock()
|
||||
|
||||
if nm.lastFailedSSID != ssid {
|
||||
return false
|
||||
}
|
||||
|
||||
elapsed := time.Now().Unix() - nm.lastFailedTime
|
||||
return elapsed < 10
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type WiFiNetwork struct {
|
||||
Connected bool `json:"connected"`
|
||||
Saved bool `json:"saved"`
|
||||
Autoconnect bool `json:"autoconnect"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Frequency uint32 `json:"frequency"`
|
||||
Mode string `json:"mode"`
|
||||
Rate uint32 `json:"rate"`
|
||||
@@ -127,6 +128,7 @@ type ConnectionRequest struct {
|
||||
AnonymousIdentity string `json:"anonymousIdentity,omitempty"`
|
||||
DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"`
|
||||
Interactive bool `json:"interactive,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
EAPMethod string `json:"eapMethod,omitempty"`
|
||||
Phase2Auth string `json:"phase2Auth,omitempty"`
|
||||
|
||||
@@ -44,6 +44,7 @@ func HandleList(conn net.Conn, req models.Request) {
|
||||
Dependencies: p.Dependencies,
|
||||
Installed: installed,
|
||||
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
|
||||
RequiresDMS: p.RequiresDMS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
|
||||
Dependencies: plugin.Dependencies,
|
||||
FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"),
|
||||
HasUpdate: hasUpdate,
|
||||
RequiresDMS: plugin.RequiresDMS,
|
||||
})
|
||||
} else {
|
||||
result = append(result, PluginInfo{
|
||||
|
||||
@@ -66,6 +66,7 @@ func HandleSearch(conn net.Conn, req models.Request) {
|
||||
Dependencies: p.Dependencies,
|
||||
Installed: installed,
|
||||
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
|
||||
RequiresDMS: p.RequiresDMS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type PluginInfo struct {
|
||||
FirstParty bool `json:"firstParty,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
HasUpdate bool `json:"hasUpdate,omitempty"`
|
||||
RequiresDMS string `json:"requires_dms,omitempty"`
|
||||
}
|
||||
|
||||
type SuccessResult struct {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -154,6 +155,15 @@ 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":
|
||||
|
||||
@@ -20,6 +20,7 @@ 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"
|
||||
@@ -65,8 +66,11 @@ var brightnessManager *brightness.Manager
|
||||
var wlrOutputManager *wlroutput.Manager
|
||||
var evdevManager *evdev.Manager
|
||||
var clipboardManager *clipboard.Manager
|
||||
var dbusManager *serverDbus.Manager
|
||||
var wlContext *wlcontext.SharedContext
|
||||
|
||||
const dbusClientID = "dms-dbus-client"
|
||||
|
||||
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
||||
var cupsSubscribers syncmap.Map[string, bool]
|
||||
var cupsSubscriberCount atomic.Int32
|
||||
@@ -363,6 +367,19 @@ 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 handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -440,6 +457,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "clipboard")
|
||||
}
|
||||
|
||||
if dbusManager != nil {
|
||||
caps = append(caps, "dbus")
|
||||
}
|
||||
|
||||
return Capabilities{Capabilities: caps}
|
||||
}
|
||||
|
||||
@@ -498,6 +519,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "clipboard")
|
||||
}
|
||||
|
||||
if dbusManager != nil {
|
||||
caps = append(caps, "dbus")
|
||||
}
|
||||
|
||||
return ServerInfo{
|
||||
APIVersion: APIVersion,
|
||||
CLIVersion: CLIVersion,
|
||||
@@ -1133,6 +1158,31 @@ 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)
|
||||
@@ -1198,6 +1248,9 @@ func cleanupManagers() {
|
||||
if clipboardManager != nil {
|
||||
clipboardManager.Close()
|
||||
}
|
||||
if dbusManager != nil {
|
||||
dbusManager.Close()
|
||||
}
|
||||
if wlContext != nil {
|
||||
wlContext.Close()
|
||||
}
|
||||
@@ -1490,6 +1543,14 @@ 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)
|
||||
|
||||
|
||||
@@ -124,27 +124,23 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
}
|
||||
|
||||
for {
|
||||
sc.drainCmdQueue()
|
||||
|
||||
select {
|
||||
case <-sc.stopChan:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
sc.drainCmdQueue()
|
||||
|
||||
n, err := unix.Poll(pollFds, 50)
|
||||
if err != nil {
|
||||
if err == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
_, err := unix.Poll(pollFds, -1)
|
||||
switch {
|
||||
case err == unix.EINTR:
|
||||
continue
|
||||
case err != nil:
|
||||
log.Errorf("Poll error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if pollFds[1].Revents&unix.POLLIN != 0 {
|
||||
var buf [64]byte
|
||||
if _, err := unix.Read(sc.wakeR, buf[:]); err != nil && err != unix.EAGAIN {
|
||||
@@ -152,13 +148,13 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
}
|
||||
}
|
||||
|
||||
if pollFds[0].Revents&unix.POLLIN != 0 {
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
if !os.IsTimeout(err) {
|
||||
log.Errorf("Wayland connection error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if pollFds[0].Revents&unix.POLLIN == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := ctx.Dispatch(); err != nil && !os.IsTimeout(err) {
|
||||
log.Errorf("Wayland connection error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,12 +172,16 @@ func (sc *SharedContext) drainCmdQueue() {
|
||||
|
||||
func (sc *SharedContext) Close() {
|
||||
close(sc.stopChan)
|
||||
if _, err := unix.Write(sc.wakeW, []byte{1}); err != nil && err != unix.EAGAIN {
|
||||
log.Errorf("wake pipe write error on close: %v", err)
|
||||
}
|
||||
sc.wg.Wait()
|
||||
|
||||
unix.Close(sc.wakeR)
|
||||
unix.Close(sc.wakeW)
|
||||
|
||||
if sc.display != nil {
|
||||
sc.display.Context().Close()
|
||||
if sc.display == nil {
|
||||
return
|
||||
}
|
||||
sc.display.Context().Close()
|
||||
}
|
||||
|
||||
20
core/internal/utils/dbus.go
Normal file
20
core/internal/utils/dbus.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
func IsDBusServiceAvailable(busName string) bool {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
obj := conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus")
|
||||
var owned bool
|
||||
if err := obj.Call("org.freedesktop.DBus.NameHasOwner", 0, busName).Store(&owned); err != nil {
|
||||
return false
|
||||
}
|
||||
return owned
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
package utils
|
||||
|
||||
import "os/exec"
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type AppChecker interface {
|
||||
CommandExists(cmd string) bool
|
||||
AnyCommandExists(cmds ...string) bool
|
||||
FlatpakExists(name string) bool
|
||||
AnyFlatpakExists(flatpaks ...string) bool
|
||||
}
|
||||
|
||||
type DefaultAppChecker struct{}
|
||||
|
||||
func (DefaultAppChecker) CommandExists(cmd string) bool {
|
||||
return CommandExists(cmd)
|
||||
}
|
||||
|
||||
func (DefaultAppChecker) AnyCommandExists(cmds ...string) bool {
|
||||
return AnyCommandExists(cmds...)
|
||||
}
|
||||
|
||||
func (DefaultAppChecker) FlatpakExists(name string) bool {
|
||||
return FlatpakExists(name)
|
||||
}
|
||||
|
||||
func (DefaultAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
|
||||
return AnyFlatpakExists(flatpaks...)
|
||||
}
|
||||
|
||||
func CommandExists(cmd string) bool {
|
||||
_, err := exec.LookPath(cmd)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -56,6 +57,10 @@ func FlatpakSearchBySubstring(substring string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func AnyFlatpakExists(flatpaks ...string) bool {
|
||||
return slices.ContainsFunc(flatpaks, FlatpakExists)
|
||||
}
|
||||
|
||||
func FlatpakInstallationDir(name string) (string, error) {
|
||||
if !FlatpakInPath() {
|
||||
return "", errors.New("flatpak not found in PATH")
|
||||
|
||||
@@ -208,3 +208,64 @@ func TestFlatpakInstallationDirCommandFailure(t *testing.T) {
|
||||
t.Errorf("expected 'not installed' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyFlatpakExistsSomeExist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fakeFlatpak := filepath.Join(tempDir, "flatpak")
|
||||
|
||||
// Script that succeeds only for "app.exists.test"
|
||||
script := `#!/bin/sh
|
||||
if [ "$1" = "info" ] && [ "$2" = "app.exists.test" ]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`
|
||||
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fake flatpak: %v", err)
|
||||
}
|
||||
|
||||
originalPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", tempDir+":"+originalPath)
|
||||
|
||||
result := AnyFlatpakExists("com.nonexistent.flatpak", "app.exists.test", "com.another.nonexistent")
|
||||
if !result {
|
||||
t.Errorf("expected true when at least one flatpak exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyFlatpakExistsNoneExist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fakeFlatpak := filepath.Join(tempDir, "flatpak")
|
||||
|
||||
script := "#!/bin/sh\nexit 1\n"
|
||||
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fake flatpak: %v", err)
|
||||
}
|
||||
|
||||
originalPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", tempDir+":"+originalPath)
|
||||
|
||||
result := AnyFlatpakExists("com.nonexistent.flatpak1", "com.nonexistent.flatpak2")
|
||||
if result {
|
||||
t.Errorf("expected false when no flatpaks exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyFlatpakExistsNoFlatpak(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("PATH", tempDir)
|
||||
|
||||
result := AnyFlatpakExists("any.package.name", "another.package")
|
||||
if result {
|
||||
t.Errorf("expected false when flatpak not in PATH, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyFlatpakExistsEmpty(t *testing.T) {
|
||||
result := AnyFlatpakExists()
|
||||
if result {
|
||||
t.Errorf("expected false when no flatpaks specified")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,38 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func XDGStateHome() string {
|
||||
if dir := os.Getenv("XDG_STATE_HOME"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".local", "state")
|
||||
}
|
||||
|
||||
func XDGDataHome() string {
|
||||
if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".local", "share")
|
||||
}
|
||||
|
||||
func XDGCacheHome() string {
|
||||
if dir, err := os.UserCacheDir(); err == nil {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".cache")
|
||||
}
|
||||
|
||||
func XDGConfigHome() string {
|
||||
if dir, err := os.UserConfigDir(); err == nil {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
|
||||
func ExpandPath(path string) (string, error) {
|
||||
expanded := os.ExpandEnv(path)
|
||||
expanded = filepath.Clean(expanded)
|
||||
|
||||
69
core/pkg/dbusutil/variant.go
Normal file
69
core/pkg/dbusutil/variant.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package dbusutil
|
||||
|
||||
import "github.com/godbus/dbus/v5"
|
||||
|
||||
func As[T any](v dbus.Variant) (T, bool) {
|
||||
val, ok := v.Value().(T)
|
||||
return val, ok
|
||||
}
|
||||
|
||||
func AsOr[T any](v dbus.Variant, def T) T {
|
||||
if val, ok := v.Value().(T); ok {
|
||||
return val
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func Get[T any](m map[string]dbus.Variant, key string) (T, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return As[T](v)
|
||||
}
|
||||
|
||||
func GetOr[T any](m map[string]dbus.Variant, key string, def T) T {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return AsOr(v, def)
|
||||
}
|
||||
|
||||
func Normalize(v any) any {
|
||||
switch val := v.(type) {
|
||||
case dbus.Variant:
|
||||
return Normalize(val.Value())
|
||||
case dbus.ObjectPath:
|
||||
return string(val)
|
||||
case []dbus.ObjectPath:
|
||||
result := make([]string, len(val))
|
||||
for i, p := range val {
|
||||
result[i] = string(p)
|
||||
}
|
||||
return result
|
||||
case map[string]dbus.Variant:
|
||||
result := make(map[string]any)
|
||||
for k, vv := range val {
|
||||
result[k] = Normalize(vv.Value())
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(val))
|
||||
for i, item := range val {
|
||||
result[i] = Normalize(item)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeSlice(values []any) []any {
|
||||
result := make([]any, len(values))
|
||||
for i, v := range values {
|
||||
result[i] = Normalize(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
155
core/pkg/dbusutil/variant_test.go
Normal file
155
core/pkg/dbusutil/variant_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package dbusutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAs(t *testing.T) {
|
||||
t.Run("string", func(t *testing.T) {
|
||||
v := dbus.MakeVariant("hello")
|
||||
val, ok := As[string](v)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "hello", val)
|
||||
})
|
||||
|
||||
t.Run("bool", func(t *testing.T) {
|
||||
v := dbus.MakeVariant(true)
|
||||
val, ok := As[bool](v)
|
||||
assert.True(t, ok)
|
||||
assert.True(t, val)
|
||||
})
|
||||
|
||||
t.Run("int32", func(t *testing.T) {
|
||||
v := dbus.MakeVariant(int32(42))
|
||||
val, ok := As[int32](v)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, int32(42), val)
|
||||
})
|
||||
|
||||
t.Run("wrong type", func(t *testing.T) {
|
||||
v := dbus.MakeVariant("hello")
|
||||
_, ok := As[int](v)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAsOr(t *testing.T) {
|
||||
t.Run("exists", func(t *testing.T) {
|
||||
v := dbus.MakeVariant("hello")
|
||||
val := AsOr(v, "default")
|
||||
assert.Equal(t, "hello", val)
|
||||
})
|
||||
|
||||
t.Run("wrong type uses default", func(t *testing.T) {
|
||||
v := dbus.MakeVariant(123)
|
||||
val := AsOr(v, "default")
|
||||
assert.Equal(t, "default", val)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
m := map[string]dbus.Variant{
|
||||
"name": dbus.MakeVariant("test"),
|
||||
"enabled": dbus.MakeVariant(true),
|
||||
"count": dbus.MakeVariant(int32(5)),
|
||||
}
|
||||
|
||||
t.Run("exists", func(t *testing.T) {
|
||||
val, ok := Get[string](m, "name")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test", val)
|
||||
})
|
||||
|
||||
t.Run("missing key", func(t *testing.T) {
|
||||
_, ok := Get[string](m, "missing")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("wrong type", func(t *testing.T) {
|
||||
_, ok := Get[int](m, "name")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetOr(t *testing.T) {
|
||||
m := map[string]dbus.Variant{
|
||||
"name": dbus.MakeVariant("test"),
|
||||
}
|
||||
|
||||
t.Run("exists", func(t *testing.T) {
|
||||
val := GetOr(m, "name", "default")
|
||||
assert.Equal(t, "test", val)
|
||||
})
|
||||
|
||||
t.Run("missing uses default", func(t *testing.T) {
|
||||
val := GetOr(m, "missing", "default")
|
||||
assert.Equal(t, "default", val)
|
||||
})
|
||||
|
||||
t.Run("wrong type uses default", func(t *testing.T) {
|
||||
val := GetOr(m, "name", 42)
|
||||
assert.Equal(t, 42, val)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
t.Run("variant unwrap", func(t *testing.T) {
|
||||
v := dbus.MakeVariant("hello")
|
||||
result := Normalize(v)
|
||||
assert.Equal(t, "hello", result)
|
||||
})
|
||||
|
||||
t.Run("nested variant", func(t *testing.T) {
|
||||
v := dbus.MakeVariant(dbus.MakeVariant("nested"))
|
||||
result := Normalize(v)
|
||||
assert.Equal(t, "nested", result)
|
||||
})
|
||||
|
||||
t.Run("object path", func(t *testing.T) {
|
||||
v := dbus.ObjectPath("/org/test")
|
||||
result := Normalize(v)
|
||||
assert.Equal(t, "/org/test", result)
|
||||
})
|
||||
|
||||
t.Run("object path slice", func(t *testing.T) {
|
||||
v := []dbus.ObjectPath{"/org/a", "/org/b"}
|
||||
result := Normalize(v)
|
||||
assert.Equal(t, []string{"/org/a", "/org/b"}, result)
|
||||
})
|
||||
|
||||
t.Run("variant map", func(t *testing.T) {
|
||||
v := map[string]dbus.Variant{
|
||||
"key": dbus.MakeVariant("value"),
|
||||
}
|
||||
result := Normalize(v)
|
||||
expected := map[string]any{"key": "value"}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("any slice", func(t *testing.T) {
|
||||
v := []any{dbus.MakeVariant("a"), dbus.ObjectPath("/b")}
|
||||
result := Normalize(v)
|
||||
expected := []any{"a", "/b"}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("passthrough primitives", func(t *testing.T) {
|
||||
assert.Equal(t, "hello", Normalize("hello"))
|
||||
assert.Equal(t, 42, Normalize(42))
|
||||
assert.Equal(t, true, Normalize(true))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeSlice(t *testing.T) {
|
||||
input := []any{
|
||||
dbus.MakeVariant("a"),
|
||||
dbus.ObjectPath("/b"),
|
||||
"c",
|
||||
}
|
||||
result := NormalizeSlice(input)
|
||||
expected := []any{"a", "/b", "c"}
|
||||
assert.Equal(t, expected, result)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func (c *CUPSClient) RejectJobs(printer string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AddPrinterToClass adds a printer to a class, if the class does not exists it will be crated
|
||||
// AddPrinterToClass adds a printer to a class, if the class does not exists it will be created
|
||||
func (c *CUPSClient) AddPrinterToClass(class, printer string) error {
|
||||
attributes, err := c.GetPrinterAttributes(class, []string{AttributeMemberURIs})
|
||||
if err != nil && !IsNotExistsError(err) {
|
||||
|
||||
@@ -15,7 +15,6 @@ Depends: ${misc:Depends},
|
||||
quickshell-git | quickshell,
|
||||
accountsservice,
|
||||
cava,
|
||||
cliphist,
|
||||
danksearch,
|
||||
dgop,
|
||||
matugen,
|
||||
@@ -29,8 +28,7 @@ Depends: ${misc:Depends},
|
||||
qml6-module-qtquick-layouts,
|
||||
qml6-module-qtquick-templates,
|
||||
qml6-module-qtquick-window,
|
||||
qt6ct,
|
||||
wl-clipboard
|
||||
qt6ct
|
||||
Provides: dms
|
||||
Conflicts: dms
|
||||
Replaces: dms
|
||||
|
||||
@@ -14,7 +14,6 @@ Depends: ${misc:Depends},
|
||||
quickshell | quickshell-git,
|
||||
accountsservice,
|
||||
cava,
|
||||
cliphist,
|
||||
danksearch,
|
||||
dgop,
|
||||
matugen,
|
||||
@@ -28,8 +27,7 @@ Depends: ${misc:Depends},
|
||||
qml6-module-qtquick-layouts,
|
||||
qml6-module-qtquick-templates,
|
||||
qml6-module-qtquick-window,
|
||||
qt6ct,
|
||||
wl-clipboard
|
||||
qt6ct
|
||||
Conflicts: dms-git
|
||||
Replaces: dms-git
|
||||
Description: DankMaterialShell - Modern Wayland Desktop Shell
|
||||
|
||||
@@ -33,7 +33,6 @@ Recommends: cava
|
||||
Recommends: danksearch
|
||||
Recommends: matugen
|
||||
Recommends: quickshell-git
|
||||
Recommends: wl-clipboard
|
||||
|
||||
# Recommended system packages
|
||||
Recommends: NetworkManager
|
||||
|
||||
@@ -24,10 +24,8 @@ Requires: dms-cli = %{version}-%{release}
|
||||
Requires: dgop
|
||||
|
||||
Recommends: cava
|
||||
Recommends: cliphist
|
||||
Recommends: danksearch
|
||||
Recommends: matugen
|
||||
Recommends: wl-clipboard
|
||||
Recommends: NetworkManager
|
||||
Recommends: qt6-qtmultimedia
|
||||
Suggests: qt6ct
|
||||
|
||||
@@ -19,7 +19,8 @@ in
|
||||
]
|
||||
++ lib.optional cfg.enableDynamicTheming pkgs.matugen
|
||||
++ lib.optional cfg.enableAudioWavelength pkgs.cava
|
||||
++ lib.optional cfg.enableCalendarEvents pkgs.khal;
|
||||
++ lib.optional cfg.enableCalendarEvents pkgs.khal
|
||||
++ lib.optional cfg.enableClipboardPaste pkgs.wtype;
|
||||
|
||||
plugins = lib.mapAttrs (name: plugin: {
|
||||
source = plugin.src;
|
||||
|
||||
@@ -11,12 +11,18 @@ let
|
||||
|
||||
inherit (config.services.greetd.settings.default_session) user;
|
||||
|
||||
compositorPackage =
|
||||
let
|
||||
configured = lib.attrByPath [ "programs" cfg.compositor.name "package" ] null config;
|
||||
in
|
||||
if configured != null then configured else builtins.getAttr cfg.compositor.name pkgs;
|
||||
|
||||
cacheDir = "/var/lib/dms-greeter";
|
||||
greeterScript = pkgs.writeShellScriptBin "dms-greeter" ''
|
||||
export PATH=$PATH:${
|
||||
lib.makeBinPath [
|
||||
cfg.quickshell.package
|
||||
config.programs.${cfg.compositor.name}.package
|
||||
compositorPackage
|
||||
]
|
||||
}
|
||||
${
|
||||
@@ -64,6 +70,7 @@ in
|
||||
"niri"
|
||||
"hyprland"
|
||||
"sway"
|
||||
"labwc"
|
||||
];
|
||||
description = "Compositor to run greeter in";
|
||||
};
|
||||
|
||||
@@ -16,6 +16,12 @@ let
|
||||
dmsPkgs
|
||||
;
|
||||
};
|
||||
hasPluginSettings = lib.any (plugin: plugin.settings != { }) (
|
||||
lib.attrValues (lib.filterAttrs (n: v: v.enable) cfg.plugins)
|
||||
);
|
||||
pluginSettings = lib.mapAttrs (name: plugin: { enabled = plugin.enable; } // plugin.settings) (
|
||||
lib.filterAttrs (n: v: v.enable) cfg.plugins
|
||||
);
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
@@ -24,25 +30,55 @@ in
|
||||
"programs"
|
||||
"dank-material-shell"
|
||||
"enableNightMode"
|
||||
] "Night mode is now always available.")
|
||||
] "Night mode is now always available")
|
||||
(lib.mkRemovedOptionModule [
|
||||
"programs"
|
||||
"dank-material-shell"
|
||||
"default"
|
||||
"settings"
|
||||
] "Default settings have been removed and been replaced with programs.dank-material-shell.settings")
|
||||
(lib.mkRemovedOptionModule [
|
||||
"programs"
|
||||
"dank-material-shell"
|
||||
"default"
|
||||
"session"
|
||||
] "Default session has been removed and been replaced with programs.dank-material-shell.session")
|
||||
(lib.mkRenamedOptionModule
|
||||
[ "programs" "dank-material-shell" "enableSystemd" ]
|
||||
[ "programs" "dank-material-shell" "systemd" "enable" ]
|
||||
)
|
||||
];
|
||||
|
||||
options.programs.dank-material-shell = with lib.types; {
|
||||
default = {
|
||||
settings = lib.mkOption {
|
||||
type = jsonFormat.type;
|
||||
default = { };
|
||||
description = "The default settings are only read if the settings.json file don't exist";
|
||||
};
|
||||
session = lib.mkOption {
|
||||
type = jsonFormat.type;
|
||||
default = { };
|
||||
description = "The default session are only read if the session.json file don't exist";
|
||||
};
|
||||
options.programs.dank-material-shell = {
|
||||
settings = lib.mkOption {
|
||||
type = jsonFormat.type;
|
||||
default = { };
|
||||
description = "DankMaterialShell configuration settings as an attribute set, to be written to ~/.config/DankMaterialShell/settings.json.";
|
||||
};
|
||||
|
||||
clipboardSettings = lib.mkOption {
|
||||
type = jsonFormat.type;
|
||||
default = { };
|
||||
description = "DankMaterialShell clipboard settings as an attribute set, to be written to ~/.config/DankMaterialShell/clsettings.json.";
|
||||
};
|
||||
|
||||
session = lib.mkOption {
|
||||
type = jsonFormat.type;
|
||||
default = { };
|
||||
description = "DankMaterialShell session settings as an attribute set, to be written to ~/.local/state/DankMaterialShell/session.json.";
|
||||
};
|
||||
|
||||
managePluginSettings = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = hasPluginSettings;
|
||||
description = ''Whether to manage plugin settings. Automatically enabled if any plugins have settings configured.'';
|
||||
};
|
||||
|
||||
systemd.target = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = config.wayland.systemd.target;
|
||||
defaultText = lib.literalExpression "config.wayland.systemd.target";
|
||||
description = "Systemd target to bind to.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,8 +91,8 @@ in
|
||||
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
|
||||
Unit = {
|
||||
Description = "DankMaterialShell";
|
||||
PartOf = [ config.wayland.systemd.target ];
|
||||
After = [ config.wayland.systemd.target ];
|
||||
PartOf = [ cfg.systemd.target ];
|
||||
After = [ cfg.systemd.target ];
|
||||
};
|
||||
|
||||
Service = {
|
||||
@@ -64,25 +100,31 @@ in
|
||||
Restart = "on-failure";
|
||||
};
|
||||
|
||||
Install.WantedBy = [ config.wayland.systemd.target ];
|
||||
Install.WantedBy = [ cfg.systemd.target ];
|
||||
};
|
||||
|
||||
xdg.stateFile."DankMaterialShell/default-session.json" = lib.mkIf (cfg.default.session != { }) {
|
||||
source = jsonFormat.generate "default-session.json" cfg.default.session;
|
||||
xdg.stateFile."DankMaterialShell/session.json" = lib.mkIf (cfg.session != { }) {
|
||||
source = jsonFormat.generate "session.json" cfg.session;
|
||||
};
|
||||
|
||||
xdg.configFile = lib.mkMerge [
|
||||
(lib.mapAttrs' (name: value: {
|
||||
name = "DankMaterialShell/plugins/${name}";
|
||||
inherit value;
|
||||
}) common.plugins)
|
||||
{
|
||||
"DankMaterialShell/default-settings.json" = lib.mkIf (cfg.default.settings != { }) {
|
||||
source = jsonFormat.generate "default-settings.json" cfg.default.settings;
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
xdg.configFile = {
|
||||
"DankMaterialShell/settings.json" = lib.mkIf (cfg.settings != { }) {
|
||||
source = jsonFormat.generate "settings.json" cfg.settings;
|
||||
};
|
||||
"DankMaterialShell/clsettings.json" = lib.mkIf (cfg.clipboardSettings != { }) {
|
||||
source = jsonFormat.generate "clsettings.json" cfg.clipboardSettings;
|
||||
};
|
||||
"DankMaterialShell/plugin_settings.json" = lib.mkIf cfg.managePluginSettings {
|
||||
source = jsonFormat.generate "plugin_settings.json" pluginSettings;
|
||||
};
|
||||
}
|
||||
// (lib.mapAttrs' (name: value: {
|
||||
name = "DankMaterialShell/plugins/${name}";
|
||||
inherit value;
|
||||
}) common.plugins);
|
||||
warnings =
|
||||
lib.optional (!cfg.managePluginSettings && hasPluginSettings)
|
||||
"You have disabled managePluginSettings but provided plugin settings. These settings will be ignored.";
|
||||
home.packages = common.packages;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,15 +20,19 @@ in
|
||||
imports = [
|
||||
(import ./options.nix args)
|
||||
];
|
||||
|
||||
options.programs.dank-material-shell.systemd.target = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Systemd target to bind to.";
|
||||
default = "graphical-session.target";
|
||||
};
|
||||
config = lib.mkIf cfg.enable {
|
||||
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
|
||||
description = "DankMaterialShell";
|
||||
path = lib.mkForce [ ];
|
||||
|
||||
partOf = [ "graphical-session.target" ];
|
||||
after = [ "graphical-session.target" ];
|
||||
wantedBy = [ "graphical-session.target" ];
|
||||
partOf = [ cfg.systemd.target ];
|
||||
after = [ cfg.systemd.target ];
|
||||
wantedBy = [ cfg.systemd.target ];
|
||||
restartIfChanged = cfg.systemd.restartIfChanged;
|
||||
|
||||
serviceConfig = {
|
||||
|
||||
@@ -10,7 +10,7 @@ let
|
||||
"programs"
|
||||
"dank-material-shell"
|
||||
];
|
||||
|
||||
jsonFormat = pkgs.formats.json { };
|
||||
builtInRemovedMsg = "This is now built-in in DMS and doesn't need additional dependencies.";
|
||||
in
|
||||
{
|
||||
@@ -37,7 +37,7 @@ in
|
||||
};
|
||||
|
||||
dgop = {
|
||||
package = lib.mkPackageOption pkgs "dgop" {};
|
||||
package = lib.mkPackageOption pkgs "dgop" { };
|
||||
};
|
||||
|
||||
enableSystemMonitoring = lib.mkOption {
|
||||
@@ -70,6 +70,12 @@ in
|
||||
description = "Add calendar events support via khal";
|
||||
};
|
||||
|
||||
enableClipboardPaste = lib.mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Adds needed dependencies for directly pasting items from the clipboard history.";
|
||||
};
|
||||
|
||||
quickshell = {
|
||||
package = lib.mkPackageOption dmsPkgs "quickshell" {
|
||||
extraDescription = "The quickshell package to use (defaults to be built from source, due to unreleased features used by DMS).";
|
||||
@@ -89,6 +95,11 @@ in
|
||||
type = types.either types.package types.path;
|
||||
description = "Source of the plugin package or path";
|
||||
};
|
||||
settings = lib.mkOption {
|
||||
type = jsonFormat.type;
|
||||
default = { };
|
||||
description = "Plugin settings as an attribute set";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -20,12 +20,9 @@ Requires: accountsservice
|
||||
Requires: dgop
|
||||
|
||||
Recommends: cava
|
||||
Recommends: cliphist
|
||||
Recommends: danksearch
|
||||
Recommends: matugen
|
||||
Recommends: quickshell-git
|
||||
Recommends: wl-clipboard
|
||||
|
||||
Recommends: NetworkManager
|
||||
Recommends: qt6-qtmultimedia
|
||||
Suggests: qt6ct
|
||||
|
||||
@@ -23,12 +23,10 @@ Requires: dgop
|
||||
|
||||
# Core utilities (Highly recommended for DMS functionality)
|
||||
Recommends: cava
|
||||
Recommends: cliphist
|
||||
Recommends: danksearch
|
||||
Recommends: matugen
|
||||
Recommends: NetworkManager
|
||||
Recommends: qt6-qtmultimedia
|
||||
Recommends: wl-clipboard
|
||||
Suggests: qt6ct
|
||||
|
||||
%description
|
||||
|
||||
@@ -15,7 +15,6 @@ Depends: ${misc:Depends},
|
||||
quickshell-git | quickshell,
|
||||
accountsservice,
|
||||
cava,
|
||||
cliphist,
|
||||
danksearch,
|
||||
dgop,
|
||||
matugen,
|
||||
@@ -29,8 +28,7 @@ Depends: ${misc:Depends},
|
||||
qml6-module-qtquick-layouts,
|
||||
qml6-module-qtquick-templates,
|
||||
qml6-module-qtquick-window,
|
||||
qt6ct,
|
||||
wl-clipboard
|
||||
qt6ct
|
||||
Provides: dms
|
||||
Conflicts: dms
|
||||
Replaces: dms
|
||||
|
||||
@@ -14,7 +14,6 @@ Depends: ${misc:Depends},
|
||||
quickshell | quickshell-git,
|
||||
accountsservice,
|
||||
cava,
|
||||
cliphist,
|
||||
danksearch,
|
||||
dgop,
|
||||
matugen,
|
||||
@@ -28,8 +27,7 @@ Depends: ${misc:Depends},
|
||||
qml6-module-qtquick-layouts,
|
||||
qml6-module-qtquick-templates,
|
||||
qml6-module-qtquick-window,
|
||||
qt6ct,
|
||||
wl-clipboard
|
||||
qt6ct
|
||||
Conflicts: dms-git
|
||||
Replaces: dms-git
|
||||
Description: DankMaterialShell - Modern Wayland Desktop Shell
|
||||
|
||||
16
flake.nix
16
flake.nix
@@ -61,11 +61,13 @@
|
||||
(builtins.substring 6 2 longDate)
|
||||
];
|
||||
version =
|
||||
pkgs.lib.removePrefix "v" (pkgs.lib.trim (builtins.readFile ./quickshell/VERSION))
|
||||
+ "+date="
|
||||
+ mkDate (self.lastModifiedDate or "19700101")
|
||||
+ "_"
|
||||
+ (self.shortRev or "dirty");
|
||||
let
|
||||
rawVersion = pkgs.lib.removePrefix "v" (pkgs.lib.trim (builtins.readFile ./quickshell/VERSION));
|
||||
cleanVersion = builtins.replaceStrings [ " " ] [ "" ] rawVersion;
|
||||
dateSuffix = "+date=" + mkDate (self.lastModifiedDate or "19700101");
|
||||
revSuffix = "_" + (self.shortRev or "dirty");
|
||||
in
|
||||
"${cleanVersion}${dateSuffix}${revSuffix}";
|
||||
in
|
||||
{
|
||||
dms-shell = pkgs.buildGoModule (
|
||||
@@ -76,14 +78,14 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-DINaA5LCOWoxBIewuc39Rnwj6NdZoET7Q++B11Qg5rI=";
|
||||
vendorHash = "sha256-lXqOJ0yNlOcXuR3vcuVjFI02Hskmavcasb1Ntf3UlPM=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
"-X main.Version=${version}"
|
||||
"-X 'main.Version=${version}'"
|
||||
];
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
|
||||
@@ -1 +1 @@
|
||||
Spicy Miso
|
||||
Saffron Bloom
|
||||
|
||||
@@ -7,120 +7,109 @@ import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
|
||||
id: root
|
||||
|
||||
property var appUsageRanking: {
|
||||
|
||||
}
|
||||
property var appUsageRanking: {}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadSettings()
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
parseSettings(settingsFile.text())
|
||||
parseSettings(settingsFile.text());
|
||||
}
|
||||
|
||||
function parseSettings(content) {
|
||||
try {
|
||||
if (content && content.trim()) {
|
||||
var settings = JSON.parse(content)
|
||||
appUsageRanking = settings.appUsageRanking || {}
|
||||
var settings = JSON.parse(content);
|
||||
appUsageRanking = settings.appUsageRanking || {};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
settingsFile.setText(JSON.stringify({
|
||||
"appUsageRanking": appUsageRanking
|
||||
}, null, 2))
|
||||
"appUsageRanking": appUsageRanking
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function addAppUsage(app) {
|
||||
if (!app)
|
||||
return
|
||||
|
||||
var appId = app.id || (app.execString || app.exec || "")
|
||||
return;
|
||||
var appId = app.id || (app.execString || app.exec || "");
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
var currentRanking = Object.assign({}, appUsageRanking)
|
||||
return;
|
||||
var currentRanking = Object.assign({}, appUsageRanking);
|
||||
|
||||
if (currentRanking[appId]) {
|
||||
currentRanking[appId].usageCount = (currentRanking[appId].usageCount
|
||||
|| 1) + 1
|
||||
currentRanking[appId].lastUsed = Date.now()
|
||||
currentRanking[appId].icon = app.icon || currentRanking[appId].icon
|
||||
|| "application-x-executable"
|
||||
currentRanking[appId].name = app.name
|
||||
|| currentRanking[appId].name || ""
|
||||
currentRanking[appId].usageCount = (currentRanking[appId].usageCount || 1) + 1;
|
||||
currentRanking[appId].lastUsed = Date.now();
|
||||
currentRanking[appId].icon = app.icon ? String(app.icon) : (currentRanking[appId].icon || "application-x-executable");
|
||||
currentRanking[appId].name = app.name || currentRanking[appId].name || "";
|
||||
} else {
|
||||
currentRanking[appId] = {
|
||||
"name": app.name || "",
|
||||
"exec": app.execString || app.exec || "",
|
||||
"icon": app.icon || "application-x-executable",
|
||||
"icon": app.icon ? String(app.icon) : "application-x-executable",
|
||||
"comment": app.comment || "",
|
||||
"usageCount": 1,
|
||||
"lastUsed": Date.now()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
appUsageRanking = currentRanking
|
||||
saveSettings()
|
||||
appUsageRanking = currentRanking;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getRankedApps() {
|
||||
var apps = []
|
||||
var apps = [];
|
||||
for (var appId in appUsageRanking) {
|
||||
var appData = appUsageRanking[appId]
|
||||
var appData = appUsageRanking[appId];
|
||||
apps.push({
|
||||
"id": appId,
|
||||
"name": appData.name,
|
||||
"exec": appData.exec,
|
||||
"icon": appData.icon,
|
||||
"comment": appData.comment,
|
||||
"usageCount": appData.usageCount,
|
||||
"lastUsed": appData.lastUsed
|
||||
})
|
||||
"id": appId,
|
||||
"name": appData.name,
|
||||
"exec": appData.exec,
|
||||
"icon": appData.icon,
|
||||
"comment": appData.comment,
|
||||
"usageCount": appData.usageCount,
|
||||
"lastUsed": appData.lastUsed
|
||||
});
|
||||
}
|
||||
|
||||
return apps.sort(function (a, b) {
|
||||
if (a.usageCount !== b.usageCount)
|
||||
return b.usageCount - a.usageCount
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
return b.usageCount - a.usageCount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupAppUsageRanking(availableAppIds) {
|
||||
var currentRanking = Object.assign({}, appUsageRanking)
|
||||
var hasChanges = false
|
||||
var currentRanking = Object.assign({}, appUsageRanking);
|
||||
var hasChanges = false;
|
||||
|
||||
for (var appId in currentRanking) {
|
||||
if (availableAppIds.indexOf(appId) === -1) {
|
||||
delete currentRanking[appId]
|
||||
hasChanges = true
|
||||
delete currentRanking[appId];
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
appUsageRanking = currentRanking
|
||||
saveSettings()
|
||||
appUsageRanking = currentRanking;
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: settingsFile
|
||||
|
||||
path: StandardPaths.writableLocation(
|
||||
StandardPaths.GenericStateLocation) + "/DankMaterialShell/appusage.json"
|
||||
path: StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/DankMaterialShell/appusage.json"
|
||||
blockLoading: true
|
||||
blockWrites: true
|
||||
watchChanges: true
|
||||
onLoaded: {
|
||||
parseSettings(settingsFile.text())
|
||||
parseSettings(settingsFile.text());
|
||||
}
|
||||
onLoadFailed: error => {}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ const KEY_MAP = {
|
||||
16777349: "XF86AudioMedia",
|
||||
16777350: "XF86AudioRecord",
|
||||
16842798: "XF86MonBrightnessUp",
|
||||
16777394: "XF86MonBrightnessUp",
|
||||
16842797: "XF86MonBrightnessDown",
|
||||
16777395: "XF86MonBrightnessDown",
|
||||
16842800: "XF86KbdBrightnessUp",
|
||||
16842799: "XF86KbdBrightnessDown",
|
||||
16842796: "XF86PowerOff",
|
||||
|
||||
@@ -103,7 +103,7 @@ const DMS_ACTIONS = [
|
||||
{ id: "spawn dms ipc call wallpaper prev", label: "Wallpaper: Previous" }
|
||||
];
|
||||
|
||||
const COMPOSITOR_ACTIONS = {
|
||||
const NIRI_ACTIONS = {
|
||||
"Window": [
|
||||
{ id: "close-window", label: "Close Window" },
|
||||
{ id: "fullscreen-window", label: "Fullscreen" },
|
||||
@@ -179,9 +179,246 @@ const COMPOSITOR_ACTIONS = {
|
||||
]
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Window", "Monitor", "Screenshot", "System", "Overview", "Alt-Tab", "Other"];
|
||||
const MANGOWC_ACTIONS = {
|
||||
"Window": [
|
||||
{ id: "killclient", label: "Close Window" },
|
||||
{ id: "focuslast", label: "Focus Last Window" },
|
||||
{ id: "focusstack next", label: "Focus Next in Stack" },
|
||||
{ id: "focusstack prev", label: "Focus Previous in Stack" },
|
||||
{ id: "focusdir left", label: "Focus Left" },
|
||||
{ id: "focusdir right", label: "Focus Right" },
|
||||
{ id: "focusdir up", label: "Focus Up" },
|
||||
{ id: "focusdir down", label: "Focus Down" },
|
||||
{ id: "exchange_client left", label: "Swap Left" },
|
||||
{ id: "exchange_client right", label: "Swap Right" },
|
||||
{ id: "exchange_client up", label: "Swap Up" },
|
||||
{ id: "exchange_client down", label: "Swap Down" },
|
||||
{ id: "exchange_stack_client next", label: "Swap Next in Stack" },
|
||||
{ id: "exchange_stack_client prev", label: "Swap Previous in Stack" },
|
||||
{ id: "togglefloating", label: "Toggle Floating" },
|
||||
{ id: "togglefullscreen", label: "Toggle Fullscreen" },
|
||||
{ id: "togglefakefullscreen", label: "Toggle Fake Fullscreen" },
|
||||
{ id: "togglemaximizescreen", label: "Toggle Maximize" },
|
||||
{ id: "toggleglobal", label: "Toggle Global (Sticky)" },
|
||||
{ id: "toggleoverlay", label: "Toggle Overlay" },
|
||||
{ id: "minimized", label: "Minimize Window" },
|
||||
{ id: "restore_minimized", label: "Restore Minimized" },
|
||||
{ id: "toggle_render_border", label: "Toggle Border" },
|
||||
{ id: "centerwin", label: "Center Window" },
|
||||
{ id: "zoom", label: "Swap with Master" }
|
||||
],
|
||||
"Move/Resize": [
|
||||
{ id: "smartmovewin left", label: "Smart Move Left" },
|
||||
{ id: "smartmovewin right", label: "Smart Move Right" },
|
||||
{ id: "smartmovewin up", label: "Smart Move Up" },
|
||||
{ id: "smartmovewin down", label: "Smart Move Down" },
|
||||
{ id: "smartresizewin left", label: "Smart Resize Left" },
|
||||
{ id: "smartresizewin right", label: "Smart Resize Right" },
|
||||
{ id: "smartresizewin up", label: "Smart Resize Up" },
|
||||
{ id: "smartresizewin down", label: "Smart Resize Down" },
|
||||
{ id: "movewin", label: "Move Window (x,y)" },
|
||||
{ id: "resizewin", label: "Resize Window (w,h)" }
|
||||
],
|
||||
"Tags": [
|
||||
{ id: "view", label: "View Tag" },
|
||||
{ id: "viewtoleft", label: "View Left Tag" },
|
||||
{ id: "viewtoright", label: "View Right Tag" },
|
||||
{ id: "viewtoleft_have_client", label: "View Left (with client)" },
|
||||
{ id: "viewtoright_have_client", label: "View Right (with client)" },
|
||||
{ id: "viewcrossmon", label: "View Cross-Monitor" },
|
||||
{ id: "tag", label: "Move to Tag" },
|
||||
{ id: "tagsilent", label: "Move to Tag (silent)" },
|
||||
{ id: "tagtoleft", label: "Move to Left Tag" },
|
||||
{ id: "tagtoright", label: "Move to Right Tag" },
|
||||
{ id: "tagcrossmon", label: "Move Cross-Monitor" },
|
||||
{ id: "toggletag", label: "Toggle Tag on Window" },
|
||||
{ id: "toggleview", label: "Toggle Tag View" },
|
||||
{ id: "comboview", label: "Combo View Tags" }
|
||||
],
|
||||
"Layout": [
|
||||
{ id: "setlayout", label: "Set Layout" },
|
||||
{ id: "switch_layout", label: "Cycle Layouts" },
|
||||
{ id: "set_proportion", label: "Set Proportion" },
|
||||
{ id: "switch_proportion_preset", label: "Cycle Proportion Presets" },
|
||||
{ id: "incnmaster +1", label: "Increase Masters" },
|
||||
{ id: "incnmaster -1", label: "Decrease Masters" },
|
||||
{ id: "setmfact", label: "Set Master Factor" },
|
||||
{ id: "incgaps", label: "Adjust Gaps" },
|
||||
{ id: "togglegaps", label: "Toggle Gaps" }
|
||||
],
|
||||
"Monitor": [
|
||||
{ id: "focusmon left", label: "Focus Monitor Left" },
|
||||
{ id: "focusmon right", label: "Focus Monitor Right" },
|
||||
{ id: "focusmon up", label: "Focus Monitor Up" },
|
||||
{ id: "focusmon down", label: "Focus Monitor Down" },
|
||||
{ id: "tagmon left", label: "Move to Monitor Left" },
|
||||
{ id: "tagmon right", label: "Move to Monitor Right" },
|
||||
{ id: "tagmon up", label: "Move to Monitor Up" },
|
||||
{ id: "tagmon down", label: "Move to Monitor Down" },
|
||||
{ id: "disable_monitor", label: "Disable Monitor" },
|
||||
{ id: "enable_monitor", label: "Enable Monitor" },
|
||||
{ id: "toggle_monitor", label: "Toggle Monitor" },
|
||||
{ id: "create_virtual_output", label: "Create Virtual Output" },
|
||||
{ id: "destroy_all_virtual_output", label: "Destroy Virtual Outputs" }
|
||||
],
|
||||
"Scratchpad": [
|
||||
{ id: "toggle_scratchpad", label: "Toggle Scratchpad" },
|
||||
{ id: "toggle_name_scratchpad", label: "Toggle Named Scratchpad" }
|
||||
],
|
||||
"Overview": [
|
||||
{ id: "toggleoverview", label: "Toggle Overview" }
|
||||
],
|
||||
"System": [
|
||||
{ id: "reload_config", label: "Reload Config" },
|
||||
{ id: "quit", label: "Quit MangoWC" },
|
||||
{ id: "setkeymode", label: "Set Keymode" },
|
||||
{ id: "switch_keyboard_layout", label: "Switch Keyboard Layout" },
|
||||
{ id: "setoption", label: "Set Option" },
|
||||
{ id: "toggle_trackpad_enable", label: "Toggle Trackpad" }
|
||||
]
|
||||
};
|
||||
|
||||
const ACTION_ARGS = {
|
||||
const HYPRLAND_ACTIONS = {
|
||||
"Window": [
|
||||
{ id: "killactive", label: "Close Window" },
|
||||
{ id: "forcekillactive", label: "Force Kill Window" },
|
||||
{ id: "closewindow", label: "Close Window (by selector)" },
|
||||
{ id: "killwindow", label: "Kill Window (by selector)" },
|
||||
{ id: "togglefloating", label: "Toggle Floating" },
|
||||
{ id: "setfloating", label: "Set Floating" },
|
||||
{ id: "settiled", label: "Set Tiled" },
|
||||
{ id: "fullscreen", label: "Toggle Fullscreen" },
|
||||
{ id: "fullscreenstate", label: "Set Fullscreen State" },
|
||||
{ id: "pin", label: "Pin Window" },
|
||||
{ id: "centerwindow", label: "Center Window" },
|
||||
{ id: "resizeactive", label: "Resize Active Window" },
|
||||
{ id: "moveactive", label: "Move Active Window" },
|
||||
{ id: "resizewindowpixel", label: "Resize Window (pixels)" },
|
||||
{ id: "movewindowpixel", label: "Move Window (pixels)" },
|
||||
{ id: "alterzorder", label: "Change Z-Order" },
|
||||
{ id: "bringactivetotop", label: "Bring to Top" },
|
||||
{ id: "setprop", label: "Set Window Property" },
|
||||
{ id: "toggleswallow", label: "Toggle Swallow" }
|
||||
],
|
||||
"Focus": [
|
||||
{ id: "movefocus l", label: "Focus Left" },
|
||||
{ id: "movefocus r", label: "Focus Right" },
|
||||
{ id: "movefocus u", label: "Focus Up" },
|
||||
{ id: "movefocus d", label: "Focus Down" },
|
||||
{ id: "movefocus", label: "Move Focus (direction)" },
|
||||
{ id: "cyclenext", label: "Cycle Next Window" },
|
||||
{ id: "cyclenext prev", label: "Cycle Previous Window" },
|
||||
{ id: "focuswindow", label: "Focus Window (by selector)" },
|
||||
{ id: "focuscurrentorlast", label: "Focus Current or Last" },
|
||||
{ id: "focusurgentorlast", label: "Focus Urgent or Last" }
|
||||
],
|
||||
"Move": [
|
||||
{ id: "movewindow l", label: "Move Window Left" },
|
||||
{ id: "movewindow r", label: "Move Window Right" },
|
||||
{ id: "movewindow u", label: "Move Window Up" },
|
||||
{ id: "movewindow d", label: "Move Window Down" },
|
||||
{ id: "movewindow", label: "Move Window (direction)" },
|
||||
{ id: "swapwindow l", label: "Swap Left" },
|
||||
{ id: "swapwindow r", label: "Swap Right" },
|
||||
{ id: "swapwindow u", label: "Swap Up" },
|
||||
{ id: "swapwindow d", label: "Swap Down" },
|
||||
{ id: "swapwindow", label: "Swap Window (direction)" },
|
||||
{ id: "swapnext", label: "Swap with Next" },
|
||||
{ id: "swapnext prev", label: "Swap with Previous" },
|
||||
{ id: "movecursortocorner", label: "Move Cursor to Corner" },
|
||||
{ id: "movecursor", label: "Move Cursor (x,y)" }
|
||||
],
|
||||
"Workspace": [
|
||||
{ id: "workspace", label: "Focus Workspace" },
|
||||
{ id: "workspace +1", label: "Next Workspace" },
|
||||
{ id: "workspace -1", label: "Previous Workspace" },
|
||||
{ id: "workspace e+1", label: "Next Open Workspace" },
|
||||
{ id: "workspace e-1", label: "Previous Open Workspace" },
|
||||
{ id: "workspace previous", label: "Previous Visited Workspace" },
|
||||
{ id: "workspace previous_per_monitor", label: "Previous on Monitor" },
|
||||
{ id: "workspace empty", label: "First Empty Workspace" },
|
||||
{ id: "movetoworkspace", label: "Move to Workspace" },
|
||||
{ id: "movetoworkspace +1", label: "Move to Next Workspace" },
|
||||
{ id: "movetoworkspace -1", label: "Move to Previous Workspace" },
|
||||
{ id: "movetoworkspacesilent", label: "Move to Workspace (silent)" },
|
||||
{ id: "movetoworkspacesilent +1", label: "Move to Next (silent)" },
|
||||
{ id: "movetoworkspacesilent -1", label: "Move to Previous (silent)" },
|
||||
{ id: "togglespecialworkspace", label: "Toggle Special Workspace" },
|
||||
{ id: "focusworkspaceoncurrentmonitor", label: "Focus Workspace on Current Monitor" },
|
||||
{ id: "renameworkspace", label: "Rename Workspace" }
|
||||
],
|
||||
"Monitor": [
|
||||
{ id: "focusmonitor l", label: "Focus Monitor Left" },
|
||||
{ id: "focusmonitor r", label: "Focus Monitor Right" },
|
||||
{ id: "focusmonitor u", label: "Focus Monitor Up" },
|
||||
{ id: "focusmonitor d", label: "Focus Monitor Down" },
|
||||
{ id: "focusmonitor +1", label: "Focus Next Monitor" },
|
||||
{ id: "focusmonitor -1", label: "Focus Previous Monitor" },
|
||||
{ id: "focusmonitor", label: "Focus Monitor (by selector)" },
|
||||
{ id: "movecurrentworkspacetomonitor", label: "Move Workspace to Monitor" },
|
||||
{ id: "moveworkspacetomonitor", label: "Move Specific Workspace to Monitor" },
|
||||
{ id: "swapactiveworkspaces", label: "Swap Active Workspaces" }
|
||||
],
|
||||
"Groups": [
|
||||
{ id: "togglegroup", label: "Toggle Group" },
|
||||
{ id: "changegroupactive f", label: "Next in Group" },
|
||||
{ id: "changegroupactive b", label: "Previous in Group" },
|
||||
{ id: "changegroupactive", label: "Change Active in Group" },
|
||||
{ id: "moveintogroup l", label: "Move into Group Left" },
|
||||
{ id: "moveintogroup r", label: "Move into Group Right" },
|
||||
{ id: "moveintogroup u", label: "Move into Group Up" },
|
||||
{ id: "moveintogroup d", label: "Move into Group Down" },
|
||||
{ id: "moveoutofgroup", label: "Move out of Group" },
|
||||
{ id: "movewindoworgroup l", label: "Move Window/Group Left" },
|
||||
{ id: "movewindoworgroup r", label: "Move Window/Group Right" },
|
||||
{ id: "movewindoworgroup u", label: "Move Window/Group Up" },
|
||||
{ id: "movewindoworgroup d", label: "Move Window/Group Down" },
|
||||
{ id: "movegroupwindow f", label: "Swap Forward in Group" },
|
||||
{ id: "movegroupwindow b", label: "Swap Backward in Group" },
|
||||
{ id: "lockgroups lock", label: "Lock All Groups" },
|
||||
{ id: "lockgroups unlock", label: "Unlock All Groups" },
|
||||
{ id: "lockgroups toggle", label: "Toggle Groups Lock" },
|
||||
{ id: "lockactivegroup lock", label: "Lock Active Group" },
|
||||
{ id: "lockactivegroup unlock", label: "Unlock Active Group" },
|
||||
{ id: "lockactivegroup toggle", label: "Toggle Active Group Lock" },
|
||||
{ id: "denywindowfromgroup on", label: "Deny Window from Group" },
|
||||
{ id: "denywindowfromgroup off", label: "Allow Window in Group" },
|
||||
{ id: "denywindowfromgroup toggle", label: "Toggle Deny from Group" },
|
||||
{ id: "setignoregrouplock on", label: "Ignore Group Lock" },
|
||||
{ id: "setignoregrouplock off", label: "Respect Group Lock" },
|
||||
{ id: "setignoregrouplock toggle", label: "Toggle Ignore Group Lock" }
|
||||
],
|
||||
"Layout": [
|
||||
{ id: "splitratio", label: "Adjust Split Ratio" }
|
||||
],
|
||||
"System": [
|
||||
{ id: "exit", label: "Exit Hyprland" },
|
||||
{ id: "forcerendererreload", label: "Force Renderer Reload" },
|
||||
{ id: "dpms on", label: "DPMS On" },
|
||||
{ id: "dpms off", label: "DPMS Off" },
|
||||
{ id: "dpms toggle", label: "DPMS Toggle" },
|
||||
{ id: "forceidle", label: "Force Idle" },
|
||||
{ id: "submap", label: "Enter Submap" },
|
||||
{ id: "submap reset", label: "Reset Submap" },
|
||||
{ id: "global", label: "Global Shortcut" },
|
||||
{ id: "event", label: "Emit Custom Event" }
|
||||
],
|
||||
"Pass-through": [
|
||||
{ id: "pass", label: "Pass Key to Window" },
|
||||
{ id: "sendshortcut", label: "Send Shortcut to Window" },
|
||||
{ id: "sendkeystate", label: "Send Key State" }
|
||||
]
|
||||
};
|
||||
|
||||
const COMPOSITOR_ACTIONS = {
|
||||
niri: NIRI_ACTIONS,
|
||||
mangowc: MANGOWC_ACTIONS,
|
||||
hyprland: HYPRLAND_ACTIONS
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Tags", "Window", "Move/Resize", "Focus", "Move", "Layout", "Groups", "Monitor", "Scratchpad", "Screenshot", "System", "Pass-through", "Overview", "Alt-Tab", "Other"];
|
||||
|
||||
const NIRI_ACTION_ARGS = {
|
||||
"set-column-width": {
|
||||
args: [{ name: "value", type: "text", label: "Width", placeholder: "+10%, -10%, 50%" }]
|
||||
},
|
||||
@@ -213,13 +450,257 @@ const ACTION_ARGS = {
|
||||
]
|
||||
},
|
||||
"screenshot-window": {
|
||||
args: [
|
||||
{ name: "show-pointer", type: "bool", label: "Show pointer" },
|
||||
{ name: "write-to-disk", type: "bool", label: "Save to disk" }
|
||||
]
|
||||
args: [{ name: "write-to-disk", type: "bool", label: "Save to disk" }]
|
||||
}
|
||||
};
|
||||
|
||||
const MANGOWC_ACTION_ARGS = {
|
||||
"view": {
|
||||
args: [
|
||||
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
|
||||
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
args: [
|
||||
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
|
||||
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
|
||||
]
|
||||
},
|
||||
"tagsilent": {
|
||||
args: [
|
||||
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
|
||||
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
|
||||
]
|
||||
},
|
||||
"toggletag": {
|
||||
args: [
|
||||
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
|
||||
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
|
||||
]
|
||||
},
|
||||
"toggleview": {
|
||||
args: [
|
||||
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
|
||||
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
|
||||
]
|
||||
},
|
||||
"comboview": {
|
||||
args: [{ name: "tags", type: "text", label: "Tags", placeholder: "1,2,3" }]
|
||||
},
|
||||
"setlayout": {
|
||||
args: [{ name: "layout", type: "text", label: "Layout", placeholder: "tile, monocle, grid, deck" }]
|
||||
},
|
||||
"set_proportion": {
|
||||
args: [{ name: "value", type: "text", label: "Proportion", placeholder: "0.5, +0.1, -0.1" }]
|
||||
},
|
||||
"setmfact": {
|
||||
args: [{ name: "value", type: "text", label: "Factor", placeholder: "+0.05, -0.05" }]
|
||||
},
|
||||
"incgaps": {
|
||||
args: [{ name: "value", type: "number", label: "Amount", placeholder: "+5, -5" }]
|
||||
},
|
||||
"movewin": {
|
||||
args: [{ name: "value", type: "text", label: "Position", placeholder: "x,y or +10,+10" }]
|
||||
},
|
||||
"resizewin": {
|
||||
args: [{ name: "value", type: "text", label: "Size", placeholder: "w,h or +10,+10" }]
|
||||
},
|
||||
"setkeymode": {
|
||||
args: [{ name: "mode", type: "text", label: "Mode", placeholder: "default, custom" }]
|
||||
},
|
||||
"setoption": {
|
||||
args: [{ name: "option", type: "text", label: "Option", placeholder: "option_name value" }]
|
||||
},
|
||||
"toggle_name_scratchpad": {
|
||||
args: [{ name: "name", type: "text", label: "Name", placeholder: "scratchpad name" }]
|
||||
},
|
||||
"incnmaster": {
|
||||
args: [{ name: "value", type: "number", label: "Amount", placeholder: "+1, -1" }]
|
||||
}
|
||||
};
|
||||
|
||||
const HYPRLAND_ACTION_ARGS = {
|
||||
"workspace": {
|
||||
args: [{ name: "value", type: "text", label: "Workspace", placeholder: "1, +1, -1, name:..." }]
|
||||
},
|
||||
"movetoworkspace": {
|
||||
args: [
|
||||
{ name: "workspace", type: "text", label: "Workspace", placeholder: "1, +1, special:name" },
|
||||
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"movetoworkspacesilent": {
|
||||
args: [
|
||||
{ name: "workspace", type: "text", label: "Workspace", placeholder: "1, +1, special:name" },
|
||||
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"focusworkspaceoncurrentmonitor": {
|
||||
args: [{ name: "value", type: "text", label: "Workspace", placeholder: "1, +1, name:..." }]
|
||||
},
|
||||
"togglespecialworkspace": {
|
||||
args: [{ name: "name", type: "text", label: "Name (optional)", placeholder: "scratchpad" }]
|
||||
},
|
||||
"focusmonitor": {
|
||||
args: [{ name: "value", type: "text", label: "Monitor", placeholder: "l, r, +1, DP-1" }]
|
||||
},
|
||||
"movecurrentworkspacetomonitor": {
|
||||
args: [{ name: "monitor", type: "text", label: "Monitor", placeholder: "l, r, DP-1" }]
|
||||
},
|
||||
"moveworkspacetomonitor": {
|
||||
args: [
|
||||
{ name: "workspace", type: "text", label: "Workspace", placeholder: "1, name:..." },
|
||||
{ name: "monitor", type: "text", label: "Monitor", placeholder: "DP-1" }
|
||||
]
|
||||
},
|
||||
"swapactiveworkspaces": {
|
||||
args: [
|
||||
{ name: "monitor1", type: "text", label: "Monitor 1", placeholder: "DP-1" },
|
||||
{ name: "monitor2", type: "text", label: "Monitor 2", placeholder: "DP-2" }
|
||||
]
|
||||
},
|
||||
"renameworkspace": {
|
||||
args: [
|
||||
{ name: "id", type: "number", label: "Workspace ID", placeholder: "1" },
|
||||
{ name: "name", type: "text", label: "New Name", placeholder: "work" }
|
||||
]
|
||||
},
|
||||
"fullscreen": {
|
||||
args: [{ name: "mode", type: "text", label: "Mode", placeholder: "0=full, 1=max, 2=fake" }]
|
||||
},
|
||||
"fullscreenstate": {
|
||||
args: [
|
||||
{ name: "internal", type: "text", label: "Internal", placeholder: "-1, 0, 1, 2, 3" },
|
||||
{ name: "client", type: "text", label: "Client", placeholder: "-1, 0, 1, 2, 3" }
|
||||
]
|
||||
},
|
||||
"resizeactive": {
|
||||
args: [{ name: "value", type: "text", label: "Size", placeholder: "10 -10, 20% 0" }]
|
||||
},
|
||||
"moveactive": {
|
||||
args: [{ name: "value", type: "text", label: "Position", placeholder: "10 -10, exact 100 100" }]
|
||||
},
|
||||
"resizewindowpixel": {
|
||||
args: [
|
||||
{ name: "size", type: "text", label: "Size", placeholder: "100 100" },
|
||||
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"movewindowpixel": {
|
||||
args: [
|
||||
{ name: "position", type: "text", label: "Position", placeholder: "100 100" },
|
||||
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"splitratio": {
|
||||
args: [{ name: "value", type: "text", label: "Ratio", placeholder: "+0.1, -0.1, exact 0.5" }]
|
||||
},
|
||||
"closewindow": {
|
||||
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
|
||||
},
|
||||
"killwindow": {
|
||||
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
|
||||
},
|
||||
"focuswindow": {
|
||||
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
|
||||
},
|
||||
"tagwindow": {
|
||||
args: [
|
||||
{ name: "tag", type: "text", label: "Tag", placeholder: "+mytag, -mytag" },
|
||||
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"alterzorder": {
|
||||
args: [
|
||||
{ name: "zheight", type: "text", label: "Z-Height", placeholder: "top, bottom" },
|
||||
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"setprop": {
|
||||
args: [
|
||||
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" },
|
||||
{ name: "property", type: "text", label: "Property", placeholder: "opaque, alpha..." },
|
||||
{ name: "value", type: "text", label: "Value", placeholder: "1, toggle" }
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
args: [{ name: "signal", type: "number", label: "Signal", placeholder: "9" }]
|
||||
},
|
||||
"signalwindow": {
|
||||
args: [
|
||||
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" },
|
||||
{ name: "signal", type: "number", label: "Signal", placeholder: "9" }
|
||||
]
|
||||
},
|
||||
"submap": {
|
||||
args: [{ name: "name", type: "text", label: "Submap Name", placeholder: "resize, reset" }]
|
||||
},
|
||||
"global": {
|
||||
args: [{ name: "name", type: "text", label: "Shortcut Name", placeholder: "app:action" }]
|
||||
},
|
||||
"event": {
|
||||
args: [{ name: "data", type: "text", label: "Event Data", placeholder: "custom data" }]
|
||||
},
|
||||
"pass": {
|
||||
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
|
||||
},
|
||||
"sendshortcut": {
|
||||
args: [
|
||||
{ name: "mod", type: "text", label: "Modifier", placeholder: "SUPER, ALT" },
|
||||
{ name: "key", type: "text", label: "Key", placeholder: "F4" },
|
||||
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"sendkeystate": {
|
||||
args: [
|
||||
{ name: "mod", type: "text", label: "Modifier", placeholder: "SUPER" },
|
||||
{ name: "key", type: "text", label: "Key", placeholder: "a" },
|
||||
{ name: "state", type: "text", label: "State", placeholder: "down, repeat, up" },
|
||||
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }
|
||||
]
|
||||
},
|
||||
"forceidle": {
|
||||
args: [{ name: "seconds", type: "number", label: "Seconds", placeholder: "300" }]
|
||||
},
|
||||
"movecursortocorner": {
|
||||
args: [{ name: "corner", type: "number", label: "Corner", placeholder: "0-3 (BL, BR, TR, TL)" }]
|
||||
},
|
||||
"movecursor": {
|
||||
args: [
|
||||
{ name: "x", type: "number", label: "X", placeholder: "100" },
|
||||
{ name: "y", type: "number", label: "Y", placeholder: "100" }
|
||||
]
|
||||
},
|
||||
"changegroupactive": {
|
||||
args: [{ name: "direction", type: "text", label: "Direction/Index", placeholder: "f, b, or index" }]
|
||||
},
|
||||
"movefocus": {
|
||||
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
|
||||
},
|
||||
"movewindow": {
|
||||
args: [{ name: "direction", type: "text", label: "Direction/Monitor", placeholder: "l, r, mon:DP-1" }]
|
||||
},
|
||||
"swapwindow": {
|
||||
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
|
||||
},
|
||||
"moveintogroup": {
|
||||
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
|
||||
},
|
||||
"movewindoworgroup": {
|
||||
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
|
||||
},
|
||||
"cyclenext": {
|
||||
args: [{ name: "options", type: "text", label: "Options", placeholder: "prev, tiled, floating" }]
|
||||
}
|
||||
};
|
||||
|
||||
const ACTION_ARGS = {
|
||||
niri: NIRI_ACTION_ARGS,
|
||||
mangowc: MANGOWC_ACTION_ARGS,
|
||||
hyprland: HYPRLAND_ACTION_ARGS
|
||||
};
|
||||
|
||||
const DMS_ACTION_ARGS = {
|
||||
"audio increment": {
|
||||
base: "spawn dms ipc call audio increment",
|
||||
@@ -287,12 +768,18 @@ function getDmsActions(isNiri, isHyprland) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function getCompositorCategories() {
|
||||
return Object.keys(COMPOSITOR_ACTIONS);
|
||||
function getCompositorCategories(compositor) {
|
||||
var actions = COMPOSITOR_ACTIONS[compositor];
|
||||
if (!actions)
|
||||
return [];
|
||||
return Object.keys(actions);
|
||||
}
|
||||
|
||||
function getCompositorActions(category) {
|
||||
return COMPOSITOR_ACTIONS[category] || [];
|
||||
function getCompositorActions(compositor, category) {
|
||||
var actions = COMPOSITOR_ACTIONS[compositor];
|
||||
if (!actions)
|
||||
return [];
|
||||
return actions[category] || [];
|
||||
}
|
||||
|
||||
function getCategoryOrder() {
|
||||
@@ -307,9 +794,12 @@ function findDmsAction(actionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCompositorAction(actionId) {
|
||||
for (const cat in COMPOSITOR_ACTIONS) {
|
||||
const acts = COMPOSITOR_ACTIONS[cat];
|
||||
function findCompositorAction(compositor, actionId) {
|
||||
var actions = COMPOSITOR_ACTIONS[compositor];
|
||||
if (!actions)
|
||||
return null;
|
||||
for (const cat in actions) {
|
||||
const acts = actions[cat];
|
||||
for (let i = 0; i < acts.length; i++) {
|
||||
if (acts[i].id === actionId)
|
||||
return acts[i];
|
||||
@@ -318,7 +808,7 @@ function findCompositorAction(actionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActionLabel(action) {
|
||||
function getActionLabel(action, compositor) {
|
||||
if (!action)
|
||||
return "";
|
||||
|
||||
@@ -326,10 +816,15 @@ function getActionLabel(action) {
|
||||
if (dmsAct)
|
||||
return dmsAct.label;
|
||||
|
||||
var base = action.split(" ")[0];
|
||||
var compAct = findCompositorAction(base);
|
||||
if (compAct)
|
||||
return compAct.label;
|
||||
if (compositor) {
|
||||
var compAct = findCompositorAction(compositor, action);
|
||||
if (compAct)
|
||||
return compAct.label;
|
||||
var base = action.split(" ")[0];
|
||||
compAct = findCompositorAction(compositor, base);
|
||||
if (compAct)
|
||||
return compAct.label;
|
||||
}
|
||||
|
||||
if (action.startsWith("spawn sh -c "))
|
||||
return action.slice(12).replace(/^["']|["']$/g, "");
|
||||
@@ -343,7 +838,7 @@ function getActionType(action) {
|
||||
return "compositor";
|
||||
if (action.startsWith("spawn dms ipc call "))
|
||||
return "dms";
|
||||
if (action.startsWith("spawn sh -c ") || action.startsWith("spawn bash -c "))
|
||||
if (/^spawn \w+ -c /.test(action) || action.startsWith("spawn_shell "))
|
||||
return "shell";
|
||||
if (action.startsWith("spawn "))
|
||||
return "spawn";
|
||||
@@ -364,16 +859,21 @@ function isValidAction(action) {
|
||||
case "spawn ":
|
||||
case "spawn sh -c \"\"":
|
||||
case "spawn sh -c ''":
|
||||
case "spawn_shell":
|
||||
case "spawn_shell ":
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isKnownCompositorAction(action) {
|
||||
if (!action)
|
||||
function isKnownCompositorAction(compositor, action) {
|
||||
if (!action || !compositor)
|
||||
return false;
|
||||
var found = findCompositorAction(compositor, action);
|
||||
if (found)
|
||||
return true;
|
||||
var base = action.split(" ")[0];
|
||||
return findCompositorAction(base) !== null;
|
||||
return findCompositorAction(compositor, base) !== null;
|
||||
}
|
||||
|
||||
function buildSpawnAction(command, args) {
|
||||
@@ -385,10 +885,13 @@ function buildSpawnAction(command, args) {
|
||||
return "spawn " + parts.join(" ");
|
||||
}
|
||||
|
||||
function buildShellAction(shellCmd) {
|
||||
function buildShellAction(compositor, shellCmd, shell) {
|
||||
if (!shellCmd)
|
||||
return "";
|
||||
return "spawn sh -c \"" + shellCmd.replace(/"/g, "\\\"") + "\"";
|
||||
if (compositor === "mangowc")
|
||||
return "spawn_shell " + shellCmd;
|
||||
var shellBin = shell || "sh";
|
||||
return "spawn " + shellBin + " -c \"" + shellCmd.replace(/"/g, "\\\"") + "\"";
|
||||
}
|
||||
|
||||
function parseSpawnCommand(action) {
|
||||
@@ -405,21 +908,33 @@ function parseSpawnCommand(action) {
|
||||
function parseShellCommand(action) {
|
||||
if (!action)
|
||||
return "";
|
||||
if (!action.startsWith("spawn sh -c "))
|
||||
return "";
|
||||
var content = action.slice(12);
|
||||
if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'")))
|
||||
content = content.slice(1, -1);
|
||||
return content.replace(/\\"/g, "\"");
|
||||
var match = action.match(/^spawn (\w+) -c (.+)$/);
|
||||
if (match) {
|
||||
var content = match[2];
|
||||
if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'")))
|
||||
content = content.slice(1, -1);
|
||||
return content.replace(/\\"/g, "\"");
|
||||
}
|
||||
if (action.startsWith("spawn_shell "))
|
||||
return action.slice(12);
|
||||
return "";
|
||||
}
|
||||
|
||||
function getActionArgConfig(action) {
|
||||
function getShellFromAction(action) {
|
||||
if (!action)
|
||||
return "sh";
|
||||
var match = action.match(/^spawn (\w+) -c /);
|
||||
return match ? match[1] : "sh";
|
||||
}
|
||||
|
||||
function getActionArgConfig(compositor, action) {
|
||||
if (!action)
|
||||
return null;
|
||||
|
||||
var baseAction = action.split(" ")[0];
|
||||
if (ACTION_ARGS[baseAction])
|
||||
return { type: "compositor", base: baseAction, config: ACTION_ARGS[baseAction] };
|
||||
var compositorArgs = ACTION_ARGS[compositor];
|
||||
if (compositorArgs && compositorArgs[baseAction])
|
||||
return { type: "compositor", base: baseAction, config: compositorArgs[baseAction] };
|
||||
|
||||
for (var key in DMS_ACTION_ARGS) {
|
||||
if (action.startsWith(DMS_ACTION_ARGS[key].base))
|
||||
@@ -429,7 +944,7 @@ function getActionArgConfig(action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCompositorActionArgs(action) {
|
||||
function parseCompositorActionArgs(compositor, action) {
|
||||
if (!action)
|
||||
return { base: "", args: {} };
|
||||
|
||||
@@ -437,44 +952,144 @@ function parseCompositorActionArgs(action) {
|
||||
var base = parts[0];
|
||||
var args = {};
|
||||
|
||||
if (!ACTION_ARGS[base])
|
||||
var compositorArgs = ACTION_ARGS[compositor];
|
||||
if (!compositorArgs || !compositorArgs[base])
|
||||
return { base: action, args: {} };
|
||||
|
||||
var argConfig = compositorArgs[base];
|
||||
var argParts = parts.slice(1);
|
||||
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
for (var i = 0; i < argParts.length; i++) {
|
||||
if (argParts[i] === "focus=true" || argParts[i] === "focus=false") {
|
||||
args.focus = argParts[i] === "focus=true";
|
||||
} else if (!args.index) {
|
||||
args.index = argParts[i];
|
||||
switch (compositor) {
|
||||
case "niri":
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
for (var i = 0; i < argParts.length; i++) {
|
||||
if (argParts[i] === "focus=true" || argParts[i] === "focus=false") {
|
||||
args.focus = argParts[i] === "focus=true";
|
||||
} else if (!args.index) {
|
||||
args.index = argParts[i];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
for (var k = 0; k < argParts.length; k++) {
|
||||
if (argParts[k] === "focus=true" || argParts[k] === "focus=false")
|
||||
args.focus = argParts[k] === "focus=true";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (base.startsWith("screenshot")) {
|
||||
for (var j = 0; j < argParts.length; j++) {
|
||||
var kv = argParts[j].split("=");
|
||||
if (kv.length === 2)
|
||||
args[kv[0]] = kv[1] === "true";
|
||||
}
|
||||
} else if (argParts.length > 0) {
|
||||
args.value = argParts.join(" ");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
for (var k = 0; k < argParts.length; k++) {
|
||||
if (argParts[k] === "focus=true" || argParts[k] === "focus=false")
|
||||
args.focus = argParts[k] === "focus=true";
|
||||
case "mangowc":
|
||||
if (argConfig.args && argConfig.args.length > 0 && argParts.length > 0) {
|
||||
var paramStr = argParts.join(" ");
|
||||
var paramValues = paramStr.split(",");
|
||||
for (var m = 0; m < argConfig.args.length && m < paramValues.length; m++) {
|
||||
args[argConfig.args[m].name] = paramValues[m];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "hyprland":
|
||||
if (argConfig.args && argConfig.args.length > 0) {
|
||||
switch (base) {
|
||||
case "resizewindowpixel":
|
||||
case "movewindowpixel":
|
||||
var commaIdx = argParts.join(" ").indexOf(",");
|
||||
if (commaIdx !== -1) {
|
||||
var fullStr = argParts.join(" ");
|
||||
args[argConfig.args[0].name] = fullStr.substring(0, commaIdx);
|
||||
args[argConfig.args[1].name] = fullStr.substring(commaIdx + 1);
|
||||
} else if (argParts.length > 0) {
|
||||
args[argConfig.args[0].name] = argParts.join(" ");
|
||||
}
|
||||
break;
|
||||
case "movetoworkspace":
|
||||
case "movetoworkspacesilent":
|
||||
case "tagwindow":
|
||||
case "alterzorder":
|
||||
if (argParts.length >= 2) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
args[argConfig.args[1].name] = argParts.slice(1).join(" ");
|
||||
} else if (argParts.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
}
|
||||
break;
|
||||
case "moveworkspacetomonitor":
|
||||
case "swapactiveworkspaces":
|
||||
case "renameworkspace":
|
||||
case "fullscreenstate":
|
||||
case "movecursor":
|
||||
if (argParts.length >= 2) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
args[argConfig.args[1].name] = argParts[1];
|
||||
} else if (argParts.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts[0];
|
||||
}
|
||||
break;
|
||||
case "setprop":
|
||||
if (argParts.length >= 3) {
|
||||
args.window = argParts[0];
|
||||
args.property = argParts[1];
|
||||
args.value = argParts.slice(2).join(" ");
|
||||
} else if (argParts.length === 2) {
|
||||
args.window = argParts[0];
|
||||
args.property = argParts[1];
|
||||
}
|
||||
break;
|
||||
case "sendshortcut":
|
||||
if (argParts.length >= 3) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
args.window = argParts.slice(2).join(" ");
|
||||
} else if (argParts.length >= 2) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
}
|
||||
break;
|
||||
case "sendkeystate":
|
||||
if (argParts.length >= 4) {
|
||||
args.mod = argParts[0];
|
||||
args.key = argParts[1];
|
||||
args.state = argParts[2];
|
||||
args.window = argParts.slice(3).join(" ");
|
||||
}
|
||||
break;
|
||||
case "signalwindow":
|
||||
if (argParts.length >= 2) {
|
||||
args.window = argParts[0];
|
||||
args.signal = argParts[1];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (argParts.length > 0) {
|
||||
if (argConfig.args.length === 1) {
|
||||
args[argConfig.args[0].name] = argParts.join(" ");
|
||||
} else {
|
||||
args.value = argParts.join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (base.startsWith("screenshot")) {
|
||||
for (var j = 0; j < argParts.length; j++) {
|
||||
var kv = argParts[j].split("=");
|
||||
if (kv.length === 2)
|
||||
args[kv[0]] = kv[1] === "true";
|
||||
}
|
||||
} else if (argParts.length > 0) {
|
||||
if (argParts.length > 0)
|
||||
args.value = argParts.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
return { base: base, args: args };
|
||||
}
|
||||
|
||||
function buildCompositorAction(base, args) {
|
||||
function buildCompositorAction(compositor, base, args) {
|
||||
if (!base)
|
||||
return "";
|
||||
|
||||
@@ -483,29 +1098,126 @@ function buildCompositorAction(base, args) {
|
||||
if (!args || Object.keys(args).length === 0)
|
||||
return base;
|
||||
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
if (args.index)
|
||||
parts.push(args.index);
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
switch (compositor) {
|
||||
case "niri":
|
||||
switch (base) {
|
||||
case "move-column-to-workspace":
|
||||
if (args.index)
|
||||
parts.push(args.index);
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
break;
|
||||
default:
|
||||
switch (base) {
|
||||
case "screenshot":
|
||||
if (args["show-pointer"] === true)
|
||||
parts.push("show-pointer=true");
|
||||
else if (args["show-pointer"] === false)
|
||||
parts.push("show-pointer=false");
|
||||
break;
|
||||
case "screenshot-screen":
|
||||
if (args["show-pointer"] === true)
|
||||
parts.push("show-pointer=true");
|
||||
else if (args["show-pointer"] === false)
|
||||
parts.push("show-pointer=false");
|
||||
if (args["write-to-disk"] === true)
|
||||
parts.push("write-to-disk=true");
|
||||
break;
|
||||
case "screenshot-window":
|
||||
if (args["write-to-disk"] === true)
|
||||
parts.push("write-to-disk=true");
|
||||
break;
|
||||
}
|
||||
if (args.value) {
|
||||
parts.push(args.value);
|
||||
} else if (args.index) {
|
||||
parts.push(args.index);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "move-column-to-workspace-down":
|
||||
case "move-column-to-workspace-up":
|
||||
if (args.focus === false)
|
||||
parts.push("focus=false");
|
||||
break;
|
||||
default:
|
||||
if (base.startsWith("screenshot")) {
|
||||
if (args["show-pointer"] === true)
|
||||
parts.push("show-pointer=true");
|
||||
if (args["write-to-disk"] === true)
|
||||
parts.push("write-to-disk=true");
|
||||
case "mangowc":
|
||||
var compositorArgs = ACTION_ARGS.mangowc;
|
||||
if (compositorArgs && compositorArgs[base] && compositorArgs[base].args) {
|
||||
var argConfig = compositorArgs[base].args;
|
||||
var argValues = [];
|
||||
for (var i = 0; i < argConfig.length; i++) {
|
||||
var argDef = argConfig[i];
|
||||
var val = args[argDef.name];
|
||||
if (val === undefined || val === "")
|
||||
val = argDef.default || "";
|
||||
if (val === "" && argValues.length === 0)
|
||||
continue;
|
||||
argValues.push(val);
|
||||
}
|
||||
if (argValues.length > 0)
|
||||
parts.push(argValues.join(","));
|
||||
} else if (args.value) {
|
||||
parts.push(args.value);
|
||||
} else if (args.index) {
|
||||
parts.push(args.index);
|
||||
}
|
||||
break;
|
||||
case "hyprland":
|
||||
var hyprArgs = ACTION_ARGS.hyprland;
|
||||
if (hyprArgs && hyprArgs[base] && hyprArgs[base].args) {
|
||||
var hyprConfig = hyprArgs[base].args;
|
||||
switch (base) {
|
||||
case "resizewindowpixel":
|
||||
case "movewindowpixel":
|
||||
if (args[hyprConfig[0].name])
|
||||
parts.push(args[hyprConfig[0].name]);
|
||||
if (args[hyprConfig[1].name])
|
||||
parts[parts.length - 1] += "," + args[hyprConfig[1].name];
|
||||
break;
|
||||
case "setprop":
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
if (args.property)
|
||||
parts.push(args.property);
|
||||
if (args.value)
|
||||
parts.push(args.value);
|
||||
break;
|
||||
case "sendshortcut":
|
||||
if (args.mod)
|
||||
parts.push(args.mod);
|
||||
if (args.key)
|
||||
parts.push(args.key);
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
break;
|
||||
case "sendkeystate":
|
||||
if (args.mod)
|
||||
parts.push(args.mod);
|
||||
if (args.key)
|
||||
parts.push(args.key);
|
||||
if (args.state)
|
||||
parts.push(args.state);
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
break;
|
||||
case "signalwindow":
|
||||
if (args.window)
|
||||
parts.push(args.window);
|
||||
if (args.signal)
|
||||
parts.push(args.signal);
|
||||
break;
|
||||
default:
|
||||
for (var j = 0; j < hyprConfig.length; j++) {
|
||||
var hVal = args[hyprConfig[j].name];
|
||||
if (hVal !== undefined && hVal !== "")
|
||||
parts.push(hVal);
|
||||
}
|
||||
}
|
||||
} else if (args.value) {
|
||||
parts.push(args.value);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (args.value)
|
||||
parts.push(args.value);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
|
||||
@@ -13,17 +13,16 @@ Singleton {
|
||||
property var currentModalsByScreen: ({})
|
||||
|
||||
function openModal(modal) {
|
||||
if (!modal.allowStacking) {
|
||||
closeAllModalsExcept(modal);
|
||||
}
|
||||
if (!modal.keepPopoutsOpen) {
|
||||
PopoutManager.closeAllPopouts();
|
||||
}
|
||||
TrayMenuManager.closeAllMenus();
|
||||
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
currentModalsByScreen[screenName] = modal;
|
||||
modalChanged();
|
||||
Qt.callLater(() => {
|
||||
if (!modal.allowStacking)
|
||||
closeAllModalsExcept(modal);
|
||||
if (!modal.keepPopoutsOpen)
|
||||
PopoutManager.closeAllPopouts();
|
||||
TrayMenuManager.closeAllMenus();
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal(modal) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user